mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 16:26:42 +02:00
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:
parent
99d0dc6fb0
commit
df7395e57a
88 changed files with 6683 additions and 0 deletions
15
apps-archived/clock/apps/web/src/lib/api/alarms.ts
Normal file
15
apps-archived/clock/apps/web/src/lib/api/alarms.ts
Normal 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`),
|
||||
};
|
||||
26
apps-archived/clock/apps/web/src/lib/api/client.ts
Normal file
26
apps-archived/clock/apps/web/src/lib/api/client.ts
Normal 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 };
|
||||
17
apps-archived/clock/apps/web/src/lib/api/timers.ts
Normal file
17
apps-archived/clock/apps/web/src/lib/api/timers.ts
Normal 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`),
|
||||
};
|
||||
|
|
@ -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>
|
||||
111
apps-archived/clock/apps/web/src/lib/components/WorldMap.svelte
Normal file
111
apps-archived/clock/apps/web/src/lib/components/WorldMap.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
215
apps-archived/clock/apps/web/src/lib/content/help/index.ts
Normal file
215
apps-archived/clock/apps/web/src/lib/content/help/index.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
36
apps-archived/clock/apps/web/src/lib/data/guest-seed.ts
Normal file
36
apps-archived/clock/apps/web/src/lib/data/guest-seed.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
69
apps-archived/clock/apps/web/src/lib/data/local-store.ts
Normal file
69
apps-archived/clock/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
106
apps-archived/clock/apps/web/src/lib/data/queries.ts
Normal file
106
apps-archived/clock/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
49
apps-archived/clock/apps/web/src/lib/i18n/index.ts
Normal file
49
apps-archived/clock/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
23
apps-archived/clock/apps/web/src/lib/i18n/locales/de.json
Normal file
23
apps-archived/clock/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
apps-archived/clock/apps/web/src/lib/i18n/locales/en.json
Normal file
23
apps-archived/clock/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
97
apps-archived/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
97
apps-archived/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 () => {},
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3017,
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'clock',
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
231
apps-archived/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
231
apps-archived/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal 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')}`;
|
||||
}
|
||||
13
apps-archived/clock/apps/web/src/lib/stores/tags.svelte.ts
Normal file
13
apps-archived/clock/apps/web/src/lib/stores/tags.svelte.ts
Normal 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';
|
||||
|
|
@ -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',
|
||||
});
|
||||
191
apps-archived/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal file
191
apps-archived/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
4
apps-archived/clock/apps/web/src/lib/version.ts
Normal file
4
apps-archived/clock/apps/web/src/lib/version.ts
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue