chore: add archived clock app to apps-archived/

The Clock app source is preserved in apps-archived/ for reference.
This directory is excluded from the pnpm workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 13:07:38 +02:00
parent 99d0dc6fb0
commit df7395e57a
88 changed files with 6683 additions and 0 deletions

View file

@ -0,0 +1,15 @@
/**
* Alarms API - Direct API calls for alarms
*/
import { api } from './client';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
export const alarmsApi = {
getAll: () => api.get<Alarm[]>('/alarms'),
getById: (id: string) => api.get<Alarm>(`/alarms/${id}`),
create: (input: CreateAlarmInput) => api.post<Alarm>('/alarms', input),
update: (id: string, input: UpdateAlarmInput) => api.patch<Alarm>(`/alarms/${id}`, input),
delete: (id: string) => api.delete(`/alarms/${id}`),
toggle: (id: string) => api.post<Alarm>(`/alarms/${id}/toggle`),
};

View file

@ -0,0 +1,26 @@
/**
* API Client for Clock backend
* Uses @manacore/shared-api-client for consistent error handling
*/
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017';
/**
* Clock API client instance
* - Auto token handling via authStore.getValidToken()
* - Consistent ApiResult<T> response format
* - Automatic retry on server errors (configurable)
*/
export const api = createApiClient({
baseUrl: API_URL,
apiPrefix: '/api/v1',
getAuthToken: () => authStore.getValidToken(),
timeout: 30000,
debug: import.meta.env.DEV,
});
// Re-export types for convenience
export type { ApiResult };

View file

@ -0,0 +1,17 @@
/**
* Timers API - Direct API calls for timers
*/
import { api } from './client';
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
export const timersApi = {
getAll: () => api.get<Timer[]>('/timers'),
getById: (id: string) => api.get<Timer>(`/timers/${id}`),
create: (input: CreateTimerInput) => api.post<Timer>('/timers', input),
update: (id: string, input: UpdateTimerInput) => api.patch<Timer>(`/timers/${id}`, input),
delete: (id: string) => api.delete(`/timers/${id}`),
start: (id: string) => api.post<Timer>(`/timers/${id}/start`),
pause: (id: string) => api.post<Timer>(`/timers/${id}/pause`),
reset: (id: string) => api.post<Timer>(`/timers/${id}/reset`),
};

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
type Props = {
open: boolean;
action?: 'save' | 'sync' | 'feature';
itemCount?: number;
onClose: () => void;
};
let { open, action = 'save', itemCount = 0, onClose }: Props = $props();
// Messages based on action type
const messages = {
save: {
title: 'Daten speichern',
description: 'Melde dich an, um deine Wecker und Timer dauerhaft in der Cloud zu speichern.',
},
sync: {
title: 'Daten synchronisieren',
description: 'Melde dich an, um deine Wecker und Timer auf allen Geräten zu synchronisieren.',
},
feature: {
title: 'Funktion freischalten',
description: 'Diese Funktion ist nur für angemeldete Benutzer verfügbar.',
},
};
const currentMessage = $derived(messages[action] || messages.save);
function handleLogin() {
if (browser) {
sessionStorage.setItem('auth-return-url', window.location.pathname);
}
goto('/login');
}
function handleRegister() {
if (browser) {
sessionStorage.setItem('auth-return-url', window.location.pathname);
}
goto('/register');
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={onClose}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>{currentMessage.title}</h2>
<button class="close-btn" onclick={onClose} aria-label="Schliessen">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<p>{currentMessage.description}</p>
{#if itemCount > 0}
<div class="migration-info">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
<span
>Du hast {itemCount}
{itemCount === 1 ? 'Element' : 'Elemente'} in deiner Session. Diese werden nach der Anmeldung
in deinen Account übertragen.</span
>
</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick={onClose}> Später </button>
<button class="btn btn-primary" onclick={handleLogin}> Anmelden </button>
<button class="btn btn-outline" onclick={handleRegister}> Registrieren </button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal-content {
background-color: var(--color-surface-elevated-2);
border: 1px solid var(--color-border-strong);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 28rem;
width: 100%;
padding: 1.5rem;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.modal-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-foreground, #1f2937);
margin: 0;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
color: var(--color-muted-foreground, #6b7280);
border-radius: 0.375rem;
transition: color 0.15s;
}
.close-btn:hover {
color: var(--color-foreground, #1f2937);
}
.modal-body {
margin-bottom: 1.5rem;
}
.modal-body p {
color: var(--color-muted-foreground, #6b7280);
margin: 0 0 1rem 0;
line-height: 1.5;
}
.migration-info {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--color-primary-50, #fef3c7);
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--color-primary-700, #b45309);
}
.migration-info svg {
flex-shrink: 0;
margin-top: 0.125rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn {
padding: 0.625rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.btn-primary {
background-color: var(--color-primary, #f59e0b);
color: white;
flex: 1;
}
.btn-primary:hover {
background-color: var(--color-primary-600, #d97706);
}
.btn-secondary {
background-color: var(--color-muted, #f3f4f6);
color: var(--color-muted-foreground, #6b7280);
}
.btn-secondary:hover {
background-color: var(--color-muted-200, #e5e7eb);
}
.btn-outline {
background-color: transparent;
border-color: var(--color-border, #e5e7eb);
color: var(--color-foreground, #1f2937);
}
.btn-outline:hover {
background-color: var(--color-muted, #f3f4f6);
}
</style>

View file

@ -0,0 +1,111 @@
<script lang="ts">
/**
* WorldMap - Interactive world map component for world clock
*/
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { POPULAR_TIMEZONES } from '@clock/shared';
interface Props {
selectedTimezones?: string[];
onCityClick?: (timezone: string, cityName: string) => void;
}
let { selectedTimezones = [], onCityClick }: Props = $props();
let mapContainer: HTMLDivElement;
let mapLoaded = $state(false);
// Get cities from popular timezones
const cities = POPULAR_TIMEZONES.map((tz) => ({
timezone: tz.timezone,
city: tz.city,
lat: tz.lat,
lng: tz.lng,
}));
function handleCityClick(timezone: string, cityName: string) {
onCityClick?.(timezone, cityName);
}
onMount(() => {
if (browser) {
mapLoaded = true;
}
});
</script>
<div class="world-map" bind:this={mapContainer}>
{#if mapLoaded}
<div class="map-placeholder">
<svg viewBox="0 0 800 400" class="map-svg">
<!-- Simple world outline -->
<rect x="0" y="0" width="800" height="400" fill="hsl(var(--muted))" opacity="0.3" />
<!-- City markers -->
{#each cities as city}
{@const x = ((city.lng + 180) / 360) * 800}
{@const y = ((90 - city.lat) / 180) * 400}
{@const isSelected = selectedTimezones.includes(city.timezone)}
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
<circle
cx={x}
cy={y}
r={isSelected ? 8 : 5}
fill={isSelected ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'}
class="cursor-pointer hover:opacity-80 transition-all"
/>
{#if isSelected}
<text
{x}
y={y - 12}
text-anchor="middle"
font-size="10"
fill="hsl(var(--foreground))"
class="pointer-events-none"
>
{city.city}
</text>
{/if}
</g>
{/each}
</svg>
</div>
{:else}
<div class="map-loading">
<span class="text-muted-foreground">Karte wird geladen...</span>
</div>
{/if}
</div>
<style>
.world-map {
width: 100%;
height: 300px;
background: hsl(var(--card));
border-radius: 12px;
overflow: hidden;
border: 1px solid hsl(var(--border));
}
.map-placeholder {
width: 100%;
height: 100%;
}
.map-svg {
width: 100%;
height: 100%;
}
.city-marker {
cursor: pointer;
}
.map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
daysLived: number;
lifeExpectancyYears?: number;
size?: number;
}
let { daysLived, lifeExpectancyYears = 80, size = 280 }: Props = $props();
// Calculate progress
let totalDays = $derived(Math.ceil(lifeExpectancyYears * 365.25));
let percentage = $derived(Math.min((daysLived / totalDays) * 100, 100));
let remainingDays = $derived(Math.max(totalDays - daysLived, 0));
// SVG calculations
let strokeWidth = 12;
let radius = $derived((size - strokeWidth) / 2);
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
// Animation
let animatedOffset = $state(circumference);
let mounted = $state(false);
onMount(() => {
mounted = true;
// Animate on mount
requestAnimationFrame(() => {
animatedOffset = dashOffset;
});
});
// Update animation when values change
$effect(() => {
if (mounted) {
animatedOffset = dashOffset;
}
});
</script>
<div class="circular-container">
<div class="circular-wrapper" style="width: {size}px; height: {size}px;">
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="circular-svg">
<!-- Background circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-muted-foreground) / 0.15)"
stroke-width={strokeWidth}
/>
<!-- Progress circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-primary))"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={animatedOffset}
transform="rotate(-90 {size / 2} {size / 2})"
class="progress-circle"
/>
<!-- Markers for decades -->
{#each Array(8) as _, i}
{@const angle = (i / 8) * 360 - 90}
{@const markerRadius = radius + strokeWidth / 2 + 8}
{@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)}
{@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="decade-marker">
{i * 10}
</text>
{/each}
</svg>
<!-- Center content -->
<div class="center-content">
<span class="percentage">{percentage.toFixed(1)}%</span>
<span class="label">gelebt</span>
</div>
</div>
<div class="circular-stats">
<div class="stat-row">
<div class="stat">
<span class="stat-value lived">{daysLived.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage gelebt</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value remaining">{remainingDays.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage verbleibend</span>
</div>
</div>
<p class="expectancy-note">Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung</p>
</div>
</div>
<style>
.circular-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.circular-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.circular-svg {
transform: rotate(0deg);
}
.progress-circle {
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.decade-marker {
font-size: 0.625rem;
fill: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.center-content {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.percentage {
font-size: 2.5rem;
font-weight: 200;
color: hsl(var(--color-foreground));
line-height: 1;
}
.label {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.circular-stats {
text-align: center;
}
.stat-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-divider {
width: 1px;
height: 2.5rem;
background: hsl(var(--color-border));
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-value.lived {
color: hsl(var(--color-primary));
}
.stat-value.remaining {
color: hsl(var(--color-muted-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.expectancy-note {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground) / 0.7);
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
/**
* AlarmsSkeleton - Loading skeleton for alarms page
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="alarms-skeleton" role="status" aria-label="Alarme werden geladen...">
<!-- Presets section -->
<div class="presets-grid">
{#each Array(6) as _, i}
<div class="preset-item" style="opacity: {Math.max(0.4, 1 - i * 0.1)};">
<SkeletonBox width="100%" height="64px" borderRadius="12px" />
</div>
{/each}
</div>
<!-- Alarm list -->
<div class="alarm-list">
{#each Array(3) as _, i}
<div class="alarm-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<div class="alarm-content">
<SkeletonBox width="80px" height="32px" />
<SkeletonBox width="120px" height="16px" />
</div>
<SkeletonBox width="48px" height="24px" borderRadius="12px" />
</div>
{/each}
</div>
</div>
<style>
.alarms-skeleton {
padding: 1rem;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 2rem;
}
.alarm-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.alarm-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.alarm-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 640px) {
.presets-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,90 @@
<script lang="ts">
/**
* AppLoadingSkeleton - Full page loading skeleton for initial app load
* Shows a minimal skeleton layout while auth is being checked
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
<!-- Header placeholder -->
<div class="header-skeleton">
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
<div class="header-nav">
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
</div>
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
</div>
<!-- Content placeholder - Clock specific -->
<div class="content-skeleton">
<!-- Clock display placeholder -->
<div class="clock-placeholder">
<SkeletonBox width="300px" height="300px" borderRadius="50%" />
</div>
<!-- Controls placeholder -->
<div class="controls-placeholder">
<SkeletonBox width="200px" height="48px" borderRadius="12px" />
</div>
</div>
</div>
<style>
.app-loading-skeleton {
min-height: 100vh;
background: hsl(var(--background));
}
.header-skeleton {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid hsl(var(--border));
}
.header-nav {
display: flex;
gap: 0.5rem;
}
.content-skeleton {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 80px);
gap: 2rem;
}
.clock-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.controls-placeholder {
display: flex;
gap: 1rem;
}
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
}
</style>

View file

@ -0,0 +1,85 @@
<script lang="ts">
/**
* TimersSkeleton - Loading skeleton for timers page
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="timers-skeleton" role="status" aria-label="Timer werden geladen...">
<!-- Quick presets -->
<div class="presets-row">
{#each Array(4) as _, i}
<SkeletonBox width="80px" height="40px" borderRadius="20px" />
{/each}
</div>
<!-- Timer form -->
<div class="timer-form">
<SkeletonBox width="100%" height="80px" borderRadius="12px" />
<SkeletonBox width="120px" height="44px" borderRadius="8px" />
</div>
<!-- Active timers -->
<div class="timers-list">
{#each Array(2) as _, i}
<div class="timer-item" style="opacity: {Math.max(0.4, 1 - i * 0.2)};">
<div class="timer-display">
<SkeletonBox width="150px" height="40px" />
<SkeletonBox width="80px" height="16px" />
</div>
<div class="timer-controls">
<SkeletonBox width="44px" height="44px" borderRadius="50%" />
<SkeletonBox width="44px" height="44px" borderRadius="50%" />
</div>
</div>
{/each}
</div>
</div>
<style>
.timers-skeleton {
padding: 1rem;
}
.presets-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.timer-form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
}
.timers-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.timer-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 16px;
}
.timer-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.timer-controls {
display: flex;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,58 @@
<script lang="ts">
/**
* WorldClockSkeleton - Loading skeleton for world clock page
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="world-clock-skeleton" role="status" aria-label="Weltuhren werden geladen...">
<!-- Map skeleton -->
<div class="map-skeleton">
<SkeletonBox width="100%" height="300px" borderRadius="12px" />
</div>
<!-- Clock list -->
<div class="clocks-list">
{#each Array(4) as _, i}
<div class="clock-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<div class="clock-info">
<SkeletonBox width="120px" height="24px" />
<SkeletonBox width="80px" height="16px" />
</div>
<SkeletonBox width="100px" height="36px" />
</div>
{/each}
</div>
</div>
<style>
.world-clock-skeleton {
padding: 1rem;
}
.map-skeleton {
margin-bottom: 1.5rem;
}
.clocks-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.clock-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.clock-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,13 @@
/**
* Clock App Skeleton Components
*
* App-specific skeleton loaders for loading states.
*/
// App Loading Skeleton
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// Feature Skeletons
export { default as AlarmsSkeleton } from './AlarmsSkeleton.svelte';
export { default as TimersSkeleton } from './TimersSkeleton.svelte';
export { default as WorldClockSkeleton } from './WorldClockSkeleton.svelte';

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { getClockHelpContent } from './index';
describe('Clock Help Content', () => {
it('returns valid German content', () => {
const content = getClockHelpContent('de');
expect(content.faq.length).toBeGreaterThan(0);
content.faq.forEach((faq) => {
expect(faq.id).toBeTruthy();
expect(faq.question).toBeTruthy();
expect(faq.answer).toBeTruthy();
});
expect(content.features).toBeDefined();
expect(content.contact).toBeDefined();
expect(content.contact.supportEmail).toBe('support@mana.how');
});
it('returns valid English content', () => {
const content = getClockHelpContent('en');
expect(content.faq.length).toBeGreaterThan(0);
content.faq.forEach((faq) => {
expect(faq.id).toBeTruthy();
expect(faq.question).toBeTruthy();
expect(faq.answer).toBeTruthy();
});
expect(content.features).toBeDefined();
expect(content.contact).toBeDefined();
});
it('returns same number of FAQ items for both languages', () => {
const de = getClockHelpContent('de');
const en = getClockHelpContent('en');
expect(de.faq.length).toBe(en.faq.length);
expect(de.features.length).toBe(en.features.length);
});
it('has unique FAQ IDs', () => {
const content = getClockHelpContent('de');
const ids = content.faq.map((f) => f.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View file

@ -0,0 +1,215 @@
/**
* Help content for Clock app
*/
import type { HelpContent } from '@manacore/help';
import { getPrivacyFAQs } from '@manacore/help';
export function getClockHelpContent(locale: string): HelpContent {
const isDE = locale === 'de';
return {
faq: [
{
id: 'faq-create-alarms',
question: isDE ? 'Wie erstelle ich Wecker?' : 'How do I create alarms?',
answer: isDE
? '<p>Du kannst Wecker auf verschiedene Arten erstellen:</p><ul><li><strong>Schnellwecker</strong>: Drücke <kbd>A</kbd> oder klicke auf das + Symbol im Wecker-Tab</li><li><strong>Uhrzeit wählen</strong>: Stelle Stunde und Minute ein und wähle die gewünschten Wochentage</li><li><strong>Label</strong>: Gib deinem Wecker einen Namen, z.B. "Morgenroutine"</li><li><strong>Klingelton</strong>: Wähle aus verschiedenen Tönen oder nutze einen sanften Weckton</li></ul>'
: '<p>You can create alarms in several ways:</p><ul><li><strong>Quick alarm</strong>: Press <kbd>A</kbd> or click the + icon in the Alarms tab</li><li><strong>Set time</strong>: Choose hour and minute and select the desired weekdays</li><li><strong>Label</strong>: Give your alarm a name, e.g. "Morning routine"</li><li><strong>Ringtone</strong>: Choose from various sounds or use a gentle wake-up tone</li></ul>',
category: 'features',
order: 1,
language: isDE ? 'de' : 'en',
tags: isDE ? ['wecker', 'erstellen', 'neu'] : ['alarm', 'create', 'new'],
},
{
id: 'faq-timers',
question: isDE
? 'Wie funktionieren Timer und Stoppuhr?'
: 'How do timers and the stopwatch work?',
answer: isDE
? '<p>Clock bietet zwei Zeitmesser:</p><ul><li><strong>Timer</strong>: Stelle eine Countdown-Zeit ein und starte ihn. Du kannst mehrere Timer gleichzeitig laufen lassen. Drücke <kbd>T</kbd> für einen neuen Timer.</li><li><strong>Stoppuhr</strong>: Messe verstrichene Zeit mit Rundenzeiten. Starte, pausiere und setze zurück.</li></ul><p>Beide laufen auch im Hintergrund weiter und benachrichtigen dich, wenn die Zeit abgelaufen ist.</p>'
: '<p>Clock offers two time measurement tools:</p><ul><li><strong>Timer</strong>: Set a countdown duration and start it. You can run multiple timers simultaneously. Press <kbd>T</kbd> for a new timer.</li><li><strong>Stopwatch</strong>: Measure elapsed time with lap splits. Start, pause, and reset.</li></ul><p>Both continue running in the background and notify you when time is up.</p>',
category: 'features',
order: 2,
language: isDE ? 'de' : 'en',
tags: isDE ? ['timer', 'stoppuhr', 'countdown'] : ['timer', 'stopwatch', 'countdown'],
},
{
id: 'faq-pomodoro',
question: isDE ? 'Was ist die Pomodoro-Technik?' : 'What is the Pomodoro technique?',
answer: isDE
? '<p>Die <strong>Pomodoro-Technik</strong> ist eine Zeitmanagement-Methode:</p><ol><li>Arbeite <strong>25 Minuten</strong> konzentriert (ein "Pomodoro")</li><li>Mache eine <strong>5-Minuten-Pause</strong></li><li>Nach 4 Pomodoros: <strong>15-30 Minuten</strong> längere Pause</li></ol><p>In Clock kannst du die Intervalle anpassen, deinen Fortschritt verfolgen und Statistiken über deine Produktivität einsehen.</p>'
: '<p>The <strong>Pomodoro technique</strong> is a time management method:</p><ol><li>Work for <strong>25 minutes</strong> with focus (one "Pomodoro")</li><li>Take a <strong>5-minute break</strong></li><li>After 4 Pomodoros: take a <strong>15-30 minute</strong> longer break</li></ol><p>In Clock you can customize the intervals, track your progress, and view statistics about your productivity.</p>',
category: 'features',
order: 3,
language: isDE ? 'de' : 'en',
tags: isDE
? ['pomodoro', 'produktivität', 'fokus', 'technik']
: ['pomodoro', 'productivity', 'focus', 'technique'],
},
{
id: 'faq-life-clock',
question: isDE ? 'Was ist die Life Clock?' : 'What is the Life Clock?',
answer: isDE
? '<p>Die <strong>Life Clock</strong> ist eine einzigartige Visualisierung deiner Lebenszeit:</p><ul><li>Gib dein Geburtsdatum und deine geschätzte Lebenserwartung ein</li><li>Sieh, wie viel deiner Zeit bereits vergangen ist und wie viel noch vor dir liegt</li><li>Verschiedene Darstellungen: Wochen, Monate oder Jahre als Raster</li></ul><p>Die Life Clock soll dich motivieren, deine Zeit bewusst zu nutzen — keine Angst, sondern <strong>Inspiration</strong>.</p>'
: '<p>The <strong>Life Clock</strong> is a unique visualization of your lifetime:</p><ul><li>Enter your birth date and estimated life expectancy</li><li>See how much of your time has passed and how much lies ahead</li><li>Various display modes: weeks, months, or years as a grid</li></ul><p>The Life Clock is meant to motivate you to use your time mindfully — not fear, but <strong>inspiration</strong>.</p>',
category: 'features',
order: 4,
language: isDE ? 'de' : 'en',
tags: isDE
? ['life-clock', 'lebenszeit', 'visualisierung']
: ['life-clock', 'lifetime', 'visualization'],
},
...getPrivacyFAQs(locale, {
dataTypeDE: 'Daten',
dataTypeEN: 'data',
extraBulletsDE: [
'<strong>Lokale Speicherung</strong>: Wecker und Timer werden lokal auf deinem Gerät gespeichert',
],
extraBulletsEN: [
'<strong>Local storage</strong>: Alarms and timers are stored locally on your device',
],
}),
],
features: [
{
id: 'feature-alarms',
title: isDE ? 'Wecker' : 'Alarms',
description: isDE
? 'Erstelle wiederkehrende und einmalige Wecker mit individuellen Tönen'
: 'Create recurring and one-time alarms with custom sounds',
icon: '⏰',
category: 'core',
highlights: isDE
? ['Wiederkehrende Wecker', 'Individuelle Töne', 'Schlummerfunktion', 'Labels']
: ['Recurring alarms', 'Custom sounds', 'Snooze function', 'Labels'],
content: '',
order: 1,
language: isDE ? 'de' : 'en',
},
{
id: 'feature-timers-stopwatch',
title: isDE ? 'Timer & Stoppuhr' : 'Timers & Stopwatch',
description: isDE
? 'Mehrere gleichzeitige Timer und eine Stoppuhr mit Rundenzeiten'
: 'Multiple simultaneous timers and a stopwatch with lap times',
icon: '⏱️',
category: 'core',
highlights: isDE
? ['Mehrere Timer', 'Rundenzeiten', 'Hintergrund-Benachrichtigung', 'Voreinstellungen']
: ['Multiple timers', 'Lap times', 'Background notifications', 'Presets'],
content: '',
order: 2,
language: isDE ? 'de' : 'en',
},
{
id: 'feature-pomodoro',
title: 'Pomodoro',
description: isDE
? 'Steigere deine Produktivität mit der Pomodoro-Technik und Statistiken'
: 'Boost your productivity with the Pomodoro technique and statistics',
icon: '🍅',
category: 'advanced',
highlights: isDE
? ['Anpassbare Intervalle', 'Sitzungs-Tracking', 'Statistiken', 'Benachrichtigungen']
: ['Customizable intervals', 'Session tracking', 'Statistics', 'Notifications'],
content: '',
order: 3,
language: isDE ? 'de' : 'en',
},
{
id: 'feature-world-clock',
title: isDE ? 'Weltzeituhr' : 'World Clock',
description: isDE
? 'Behalte die Uhrzeit in verschiedenen Zeitzonen im Blick'
: 'Keep track of the time across different time zones',
icon: '🌍',
category: 'core',
highlights: isDE
? ['Alle Zeitzonen', 'Zeitvergleich', 'Favoriten', 'Analoges Zifferblatt']
: ['All time zones', 'Time comparison', 'Favorites', 'Analog clock face'],
content: '',
order: 4,
language: isDE ? 'de' : 'en',
},
],
shortcuts: [
{
id: 'shortcuts-general',
category: 'general',
title: isDE ? 'Allgemein' : 'General',
language: isDE ? 'de' : 'en',
order: 1,
shortcuts: [
{
shortcut: 'Cmd/Ctrl + K',
action: isDE ? 'Kommandoleiste öffnen' : 'Open command bar',
},
{
shortcut: 'A',
action: isDE ? 'Neuer Wecker' : 'New alarm',
},
{
shortcut: 'T',
action: isDE ? 'Neuer Timer' : 'New timer',
},
],
},
{
id: 'shortcuts-navigation',
category: 'navigation',
title: 'Navigation',
language: isDE ? 'de' : 'en',
order: 2,
shortcuts: [
{
shortcut: 'Cmd/Ctrl + 1',
action: isDE ? 'Wecker öffnen' : 'Open Alarms',
},
{
shortcut: 'Cmd/Ctrl + 2',
action: isDE ? 'Timer öffnen' : 'Open Timers',
},
{
shortcut: 'Cmd/Ctrl + 3',
action: isDE ? 'Stoppuhr öffnen' : 'Open Stopwatch',
},
{
shortcut: 'Cmd/Ctrl + 4',
action: isDE ? 'Pomodoro öffnen' : 'Open Pomodoro',
},
{
shortcut: 'Cmd/Ctrl + 5',
action: isDE ? 'Weltzeituhr öffnen' : 'Open World Clock',
},
{
shortcut: 'Cmd/Ctrl + 6',
action: isDE ? 'Life Clock öffnen' : 'Open Life Clock',
},
{
shortcut: 'Cmd/Ctrl + 7',
action: isDE ? 'Statistiken öffnen' : 'Open Statistics',
},
{
shortcut: 'Cmd/Ctrl + 8',
action: isDE ? 'Einstellungen öffnen' : 'Open Settings',
},
],
},
],
gettingStarted: [],
changelog: [],
contact: {
id: 'contact-support',
title: isDE ? 'Support kontaktieren' : 'Contact Support',
content: isDE
? '<p>Unser Support-Team hilft dir bei allen Fragen rund um Clock.</p>'
: '<p>Our support team is here to help you with any questions about Clock.</p>',
language: isDE ? 'de' : 'en',
order: 1,
supportEmail: 'support@mana.how',
documentationUrl: 'https://mana.how/docs',
responseTime: isDE ? 'Normalerweise innerhalb von 24 Stunden' : 'Usually within 24 hours',
},
};
}

View file

@ -0,0 +1,36 @@
/**
* Guest seed data for the Clock app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They provide sample alarms and world clocks to showcase the app.
*/
import type { LocalAlarm, LocalWorldClock } from './local-store';
export const guestAlarms: LocalAlarm[] = [
{
id: 'alarm-weekday-morning',
label: 'Wecker Wochentags',
time: '07:00',
enabled: true,
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
snoozeMinutes: 5,
sound: null,
vibrate: true,
},
];
export const guestWorldClocks: LocalWorldClock[] = [
{
id: 'wc-new-york',
timezone: 'America/New_York',
cityName: 'New York',
sortOrder: 0,
},
{
id: 'wc-tokyo',
timezone: 'Asia/Tokyo',
cityName: 'Tokio',
sortOrder: 1,
},
];

View file

@ -0,0 +1,69 @@
/**
* Clock App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Clock data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestAlarms, guestWorldClocks } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalAlarm extends BaseRecord {
label: string | null;
time: string; // HH:mm format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
}
export interface LocalTimer extends BaseRecord {
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: 'idle' | 'running' | 'paused' | 'finished';
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
}
export interface LocalWorldClock extends BaseRecord {
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const clockStore = createLocalStore({
appId: 'clock',
collections: [
{
name: 'alarms',
indexes: ['enabled', 'time'],
guestSeed: guestAlarms,
},
{
name: 'timers',
indexes: ['status'],
},
{
name: 'worldClocks',
indexes: ['sortOrder', 'timezone'],
guestSeed: guestWorldClocks,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const alarmCollection = clockStore.collection<LocalAlarm>('alarms');
export const timerCollection = clockStore.collection<LocalTimer>('timers');
export const worldClockCollection = clockStore.collection<LocalWorldClock>('worldClocks');

View file

@ -0,0 +1,106 @@
/**
* Reactive Queries & Pure Helpers for Clock
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
alarmCollection,
timerCollection,
worldClockCollection,
type LocalAlarm,
type LocalTimer,
type LocalWorldClock,
} from './local-store';
import type { Alarm, Timer, WorldClock } from '@clock/shared';
// ─── Type Converters ───────────────────────────────────────
export function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All alarms, auto-updates on any change. */
export function useAllAlarms() {
return useLiveQueryWithDefault(async () => {
const locals = await alarmCollection.getAll();
return locals.map(toAlarm);
}, [] as Alarm[]);
}
/** All timers, auto-updates on any change. */
export function useAllTimers() {
return useLiveQueryWithDefault(async () => {
const locals = await timerCollection.getAll();
return locals.map(toTimer);
}, [] as Timer[]);
}
/** All world clocks, sorted by sortOrder. Auto-updates on any change. */
export function useAllWorldClocks() {
return useLiveQueryWithDefault(async () => {
const locals = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toWorldClock);
}, [] as WorldClock[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
return alarms.filter((a) => a.enabled);
}
export function filterActiveTimers(timers: Timer[]): Timer[] {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
}
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
}

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('clock_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('clock_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,23 @@
{
"app": {
"name": "Clock"
},
"common": {
"back": "Zurück",
"cancel": "Abbrechen",
"loading": "Lade..."
},
"nav": {
"home": "Startseite",
"settings": "Einstellungen"
},
"clock": {
"title": "Life Clock",
"remaining": "Verbleibende Zeit",
"elapsed": "Vergangene Zeit"
},
"messages": {
"saved": "Gespeichert",
"error": "Ein Fehler ist aufgetreten"
}
}

View file

@ -0,0 +1,23 @@
{
"app": {
"name": "Clock"
},
"common": {
"back": "Back",
"cancel": "Cancel",
"loading": "Loading..."
},
"nav": {
"home": "Home",
"settings": "Settings"
},
"clock": {
"title": "Life Clock",
"remaining": "Time remaining",
"elapsed": "Time elapsed"
},
"messages": {
"saved": "Saved",
"error": "An error occurred"
}
}

View file

@ -0,0 +1,97 @@
/**
* Alarms Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, toggle).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import { toAlarm } from '$lib/data/queries';
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared';
let error = $state<string | null>(null);
export const alarmsStore = {
get error() {
return error;
},
/**
* Create a new alarm -- writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
error = null;
try {
const newLocal: LocalAlarm = {
id: crypto.randomUUID(),
label: input.label ?? null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays ?? null,
snoozeMinutes: input.snoozeMinutes ?? null,
sound: input.sound ?? null,
vibrate: input.vibrate ?? null,
};
const inserted = await alarmCollection.insert(newLocal);
return { success: true, data: toAlarm(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
return { success: false, error: error };
}
},
/**
* Update an alarm -- writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
error = null;
try {
const updateData: Partial<LocalAlarm> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.time !== undefined) updateData.time = input.time;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
const updated = await alarmCollection.update(id, updateData);
if (updated) {
return { success: true, data: toAlarm(updated) };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update alarm';
console.error('Failed to update alarm:', e);
return { success: false, error: error };
}
},
/**
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
const alarm = currentAlarms.find((a) => a.id === id);
if (!alarm) return { success: false, error: 'Alarm not found' };
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete an alarm -- removes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
error = null;
try {
await alarmCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
console.error('Failed to delete alarm:', e);
return { success: false, error: error };
}
},
};

View file

@ -0,0 +1,82 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Clock-specific onboarding steps
*/
const clockOnboardingSteps: AppOnboardingStep[] = [
{
id: 'features',
type: 'info',
question: 'Willkommen bei Clock!',
description: 'Das kann Clock für dich tun:',
emoji: '🕐',
gradient: { from: 'blue-500', to: 'blue-700' },
bullets: [
'Flexible Timer & Stoppuhr',
'Pomodoro-Technik für produktives Arbeiten',
'Voreingestellte Timer-Dauern',
'Minimalistisches Design',
],
},
{
id: 'defaultTimer',
type: 'select',
question: 'Welche Timer-Dauer nutzt du am häufigsten?',
description: 'Du kannst Timer jederzeit individuell einstellen.',
emoji: '⏱️',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: '5',
label: '5 Minuten',
description: 'Für kurze Pausen',
emoji: '⚡',
},
{
id: '15',
label: '15 Minuten',
description: 'Für konzentrierte Einheiten',
emoji: '🎯',
},
{
id: '25',
label: '25 Minuten',
description: 'Pomodoro-Technik (Empfohlen)',
emoji: '🍅',
},
{
id: '45',
label: '45 Minuten',
description: 'Für längere Arbeitsphasen',
emoji: '🧘',
},
],
defaultValue: '25',
},
{
id: 'welcome',
type: 'info',
question: 'Deine Uhr ist bereit!',
description: 'Hier sind einige Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Nutze die Stoppuhr für freie Zeitmessung',
'Stelle Wecker für wichtige Erinnerungen',
'Die Weltuhr zeigt mehrere Zeitzonen gleichzeitig',
'Drücke Cmd/Ctrl+K für die Schnellsuche',
],
},
];
/**
* Clock app onboarding store
*/
export const clockOnboarding = createAppOnboardingStore({
appId: 'clock',
steps: clockOnboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -0,0 +1,9 @@
/**
* Auth Store uses centralized Mana auth factory.
*/
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore({
devBackendPort: 3017,
});

View file

@ -0,0 +1,5 @@
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed } = createSimpleNavigationStores({
storageKey: 'clock',
});

View file

@ -0,0 +1,150 @@
/**
* Session Alarms Store - Manages alarms in sessionStorage for guest users
* This allows users to try the app without signing in.
* Data is stored in sessionStorage (lost when tab closes).
*/
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
const STORAGE_KEY = 'clock-session-alarms';
// State
let alarms = $state<Alarm[]>([]);
// Generate session ID
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load from sessionStorage
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
alarms = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session alarms:', e);
}
}
// Save to sessionStorage
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms));
} catch (e) {
console.error('Failed to save session alarms:', e);
}
}
// Initialize on load
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionAlarmsStore = {
// Getters
get alarms() {
return alarms;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
/**
* Create a new session alarm
*/
createAlarm(input: CreateAlarmInput): Alarm {
const now = new Date().toISOString();
const alarm: Alarm = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays || null,
snoozeMinutes: input.snoozeMinutes || null,
sound: input.sound || null,
vibrate: input.vibrate ?? null,
createdAt: now,
updatedAt: now,
};
alarms = [...alarms, alarm];
saveToStorage();
return alarm;
},
/**
* Update a session alarm
*/
updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null {
const index = alarms.findIndex((a) => a.id === id);
if (index === -1) return null;
const updated: Alarm = {
...alarms[index],
...input,
updatedAt: new Date().toISOString(),
};
alarms = alarms.map((a) => (a.id === id ? updated : a));
saveToStorage();
return updated;
},
/**
* Toggle alarm enabled state
*/
toggleAlarm(id: string): Alarm | null {
const alarm = alarms.find((a) => a.id === id);
if (!alarm) return null;
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete a session alarm
*/
deleteAlarm(id: string): void {
alarms = alarms.filter((a) => a.id !== id);
saveToStorage();
},
/**
* Check if ID is a session alarm
*/
isSessionAlarm(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all alarms for migration
*/
getAllAlarms(): Alarm[] {
return [...alarms];
},
/**
* Clear all session data
*/
clear(): void {
alarms = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session alarms
*/
get count(): number {
return alarms.length;
},
};

View file

@ -0,0 +1,214 @@
/**
* Session Timers Store - Manages timers in sessionStorage for guest users
* This allows users to try the app without signing in.
* Data is stored in sessionStorage (lost when tab closes).
*/
import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared';
const STORAGE_KEY = 'clock-session-timers';
// State
let timers = $state<Timer[]>([]);
// Generate session ID
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Load from sessionStorage
function loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
timers = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load session timers:', e);
}
}
// Save to sessionStorage
function saveToStorage(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers));
} catch (e) {
console.error('Failed to save session timers:', e);
}
}
// Initialize on load
if (typeof window !== 'undefined') {
loadFromStorage();
}
export const sessionTimersStore = {
// Getters
get timers() {
return timers;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
/**
* Create a new session timer
*/
createTimer(input: CreateTimerInput): Timer {
const now = new Date().toISOString();
const timer: Timer = {
id: generateSessionId(),
userId: 'guest',
label: input.label || null,
durationSeconds: input.durationSeconds,
remainingSeconds: input.durationSeconds,
status: 'idle' as TimerStatus,
startedAt: null,
pausedAt: null,
sound: input.sound || null,
createdAt: now,
updatedAt: now,
};
timers = [...timers, timer];
saveToStorage();
return timer;
},
/**
* Update a session timer
*/
updateTimer(id: string, input: UpdateTimerInput): Timer | null {
const index = timers.findIndex((t) => t.id === id);
if (index === -1) return null;
const updated: Timer = {
...timers[index],
...input,
updatedAt: new Date().toISOString(),
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Start a timer
*/
startTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'running',
startedAt: now,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Pause a timer
*/
pauseTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'paused',
pausedAt: now,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Reset a timer
*/
resetTimer(id: string): Timer | null {
const timer = timers.find((t) => t.id === id);
if (!timer) return null;
const now = new Date().toISOString();
const updated: Timer = {
...timer,
status: 'idle',
remainingSeconds: timer.durationSeconds,
startedAt: null,
pausedAt: null,
updatedAt: now,
};
timers = timers.map((t) => (t.id === id ? updated : t));
saveToStorage();
return updated;
},
/**
* Update local timer state (for countdown display)
*/
updateLocalState(id: string, updates: Partial<Timer>): void {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
saveToStorage();
},
/**
* Delete a session timer
*/
deleteTimer(id: string): void {
timers = timers.filter((t) => t.id !== id);
saveToStorage();
},
/**
* Check if ID is a session timer
*/
isSessionTimer(id: string): boolean {
return id.startsWith('session_');
},
/**
* Get all timers for migration
*/
getAllTimers(): Timer[] {
return [...timers];
},
/**
* Clear all session data
*/
clear(): void {
timers = [];
if (typeof window !== 'undefined') {
sessionStorage.removeItem(STORAGE_KEY);
}
},
/**
* Get count of session timers
*/
get count(): number {
return timers.length;
},
};

View file

@ -0,0 +1,231 @@
/**
* Stopwatch Store - Manages stopwatch state using Svelte 5 runes
* Stopwatches are local-only (no backend sync)
*/
export interface Lap {
number: number;
time: number; // milliseconds since start
delta: number; // milliseconds since last lap
}
export interface Stopwatch {
id: string;
label: string;
startTime: number | null; // timestamp when started
elapsedTime: number; // accumulated milliseconds when paused
status: 'idle' | 'running' | 'paused';
laps: Lap[];
color: string;
}
export const STOPWATCH_COLORS = [
'#3B82F6', // blue
'#10B981', // green
'#F59E0B', // amber
'#EF4444', // red
'#8B5CF6', // violet
'#EC4899', // pink
'#14B8A6', // teal
'#F97316', // orange
];
// State
let stopwatches = $state<Stopwatch[]>([]);
let focusedId = $state<string | null>(null);
let colorIndex = 0;
// Tick interval for updating display
let tickInterval: ReturnType<typeof setInterval> | null = null;
function getNextColor(): string {
const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length];
colorIndex++;
return color;
}
function startTicking() {
if (tickInterval) return;
tickInterval = setInterval(() => {
// Force reactivity update by reassigning
stopwatches = [...stopwatches];
}, 100);
}
function stopTickingIfNoRunning() {
const hasRunning = stopwatches.some((sw) => sw.status === 'running');
if (!hasRunning && tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
export const stopwatchesStore = {
// Getters
get stopwatches() {
return stopwatches;
},
get focusedId() {
return focusedId;
},
get focusedStopwatch() {
return stopwatches.find((sw) => sw.id === focusedId) || null;
},
/**
* Create a new stopwatch
*/
create(label?: string): string {
const id = crypto.randomUUID();
const newStopwatch: Stopwatch = {
id,
label: label || `Stopwatch ${stopwatches.length + 1}`,
startTime: null,
elapsedTime: 0,
status: 'idle',
laps: [],
color: getNextColor(),
};
stopwatches = [...stopwatches, newStopwatch];
if (!focusedId) {
focusedId = id;
}
return id;
},
/**
* Start a stopwatch
*/
start(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: Date.now(),
status: 'running' as const,
};
});
startTicking();
},
/**
* Pause a stopwatch
*/
pause(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const elapsed = sw.startTime ? Date.now() - sw.startTime : 0;
return {
...sw,
startTime: null,
elapsedTime: sw.elapsedTime + elapsed,
status: 'paused' as const,
};
});
stopTickingIfNoRunning();
},
/**
* Reset a stopwatch
*/
reset(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id) return sw;
return {
...sw,
startTime: null,
elapsedTime: 0,
status: 'idle' as const,
laps: [],
};
});
stopTickingIfNoRunning();
},
/**
* Add a lap to a stopwatch
*/
addLap(id: string) {
stopwatches = stopwatches.map((sw) => {
if (sw.id !== id || sw.status !== 'running') return sw;
const currentTime = this.getElapsed(sw);
const lastLap = sw.laps[sw.laps.length - 1];
const delta = lastLap ? currentTime - lastLap.time : currentTime;
const newLap: Lap = {
number: sw.laps.length + 1,
time: currentTime,
delta,
};
return {
...sw,
laps: [...sw.laps, newLap],
};
});
},
/**
* Delete a stopwatch
*/
delete(id: string) {
stopwatches = stopwatches.filter((sw) => sw.id !== id);
if (focusedId === id) {
focusedId = stopwatches[0]?.id || null;
}
stopTickingIfNoRunning();
},
/**
* Set focused stopwatch
*/
setFocused(id: string | null) {
focusedId = id;
},
/**
* Update stopwatch label
*/
updateLabel(id: string, label: string) {
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
},
/**
* Get elapsed time for a stopwatch
*/
getElapsed(sw: Stopwatch): number {
if (sw.status === 'running' && sw.startTime) {
return sw.elapsedTime + (Date.now() - sw.startTime);
}
return sw.elapsedTime;
},
};
/**
* Format time in milliseconds to display string
*/
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
/**
* Format lap time (delta) for display
*/
export function formatLapTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
if (minutes > 0) {
return `+${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
return `+${seconds}.${centiseconds.toString().padStart(2, '0')}`;
}

View file

@ -0,0 +1,13 @@
/**
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -0,0 +1,7 @@
import { createThemeStore } from '@manacore/shared-theme';
// Create theme store with Clock's styling
export const theme = createThemeStore({
appId: 'clock',
defaultVariant: 'lume',
});

View file

@ -0,0 +1,191 @@
/**
* Timers Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, start, pause, reset).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { timerCollection, type LocalTimer } from '$lib/data/local-store';
import { toTimer } from '$lib/data/queries';
import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { ClockEvents } from '@manacore/shared-utils/analytics';
let error = $state<string | null>(null);
export const timersStore = {
get error() {
return error;
},
/**
* Create a new timer -- writes to IndexedDB instantly.
*/
async createTimer(input: CreateTimerInput) {
error = null;
try {
const newLocal: LocalTimer = {
id: crypto.randomUUID(),
label: input.label ?? null,
durationSeconds: input.durationSeconds,
remainingSeconds: null,
status: 'idle',
startedAt: null,
pausedAt: null,
sound: input.sound ?? null,
};
const inserted = await timerCollection.insert(newLocal);
return { success: true, data: toTimer(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
return { success: false, error: error };
}
},
/**
* Update a timer -- writes to IndexedDB instantly.
*/
async updateTimer(id: string, input: UpdateTimerInput) {
error = null;
try {
const updateData: Partial<LocalTimer> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
const updated = await timerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update timer';
console.error('Failed to update timer:', e);
return { success: false, error: error };
}
},
/**
* Start a timer -- sets status to running with current timestamp.
*/
async startTimer(id: string) {
error = null;
try {
const existing = await timerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
const updateData: Partial<LocalTimer> = {
status: 'running',
startedAt: new Date().toISOString(),
pausedAt: null,
};
// If resuming from pause, keep remaining seconds
if (existing.status !== 'paused') {
updateData.remainingSeconds = existing.durationSeconds;
}
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
ClockEvents.timerStarted(
(updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown'
);
return { success: true, data: updatedTimer };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start timer';
console.error('Failed to start timer:', e);
return { success: false, error: error };
}
},
/**
* Pause a timer -- calculates remaining seconds and saves.
*/
async pauseTimer(id: string) {
error = null;
try {
const existing = await timerCollection.get(id);
if (!existing) return { success: false, error: 'Timer not found' };
// Calculate remaining seconds
let remaining = existing.remainingSeconds ?? existing.durationSeconds;
if (existing.startedAt) {
const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000;
remaining = Math.max(0, remaining - elapsed);
}
const updateData: Partial<LocalTimer> = {
status: 'paused',
pausedAt: new Date().toISOString(),
remainingSeconds: Math.round(remaining),
startedAt: null,
};
const updated = await timerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to pause timer';
console.error('Failed to pause timer:', e);
return { success: false, error: error };
}
},
/**
* Reset a timer -- back to idle with full duration.
*/
async resetTimer(id: string) {
error = null;
try {
const updateData: Partial<LocalTimer> = {
status: 'idle',
remainingSeconds: null,
startedAt: null,
pausedAt: null,
};
const updated = await timerCollection.update(id, updateData);
if (updated) {
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reset timer';
console.error('Failed to reset timer:', e);
return { success: false, error: error };
}
},
/**
* Delete a timer -- removes from IndexedDB instantly.
*/
async deleteTimer(id: string) {
error = null;
try {
await timerCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
console.error('Failed to delete timer:', e);
return { success: false, error: error };
}
},
/**
* Update remaining seconds in IndexedDB (for countdown display).
*/
async updateLocalTimer(id: string, remainingSeconds: number) {
try {
await timerCollection.update(id, { remainingSeconds });
} catch (e) {
console.error('Failed to update local timer:', e);
}
},
};

View file

@ -0,0 +1,28 @@
/**
* User Settings Store for Clock
*
* This store syncs settings with mana-core-auth and provides:
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
export const userSettings = createUserSettingsStore({
appId: 'clock',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,74 @@
/**
* World Clocks Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (add, remove, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { CreateWorldClockInput, WorldClock } from '@clock/shared';
let error = $state<string | null>(null);
export const worldClocksStore = {
get error() {
return error;
},
/**
* Add a new world clock -- writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: currentCount,
};
await worldClockCollection.insert(newLocal);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
return { success: false, error: error };
}
},
/**
* Remove a world clock -- removes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
error = null;
try {
await worldClockCollection.delete(id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
console.error('Failed to remove world clock:', e);
return { success: false, error: error };
}
},
/**
* Reorder world clocks -- updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
error = null;
try {
for (let i = 0; i < ids.length; i++) {
await worldClockCollection.update(ids[i], {
sortOrder: i,
} as Partial<LocalWorldClock>);
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
console.error('Failed to reorder world clocks:', e);
return { success: false, error: error };
}
},
};

View file

@ -0,0 +1,4 @@
export const APP_VERSION = '0.2.0';
export const BUILD_TIME: string =
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';