feat: add Calc app with 8 calculator modes and 5 retro skins

New calculator app with standard, scientific, programmer, unit converter,
currency, finance, date, and percentage modes. Includes 5 visual skins:
Modern, HP-35 (1972), Casio fx (1985), TI-84 (2004), and Minimal.
Local-first with IndexedDB history, keyboard support, safe math parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 08:40:34 +02:00
parent 076e0c843d
commit 7552c351c0
68 changed files with 8174 additions and 1171 deletions

View file

@ -55,6 +55,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
| **inventar** | Inventory management | Web |
| **traces** | City exploration | Backend, Mobile |
| **taktik** | Time tracking | Web |
| **calc** | Calculator & converter | Web |
| **playground** | LLM playground | Web |
### Archived Projects (`apps-archived/`)
@ -566,6 +567,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
| SkilltTree | skills, activities, achievements | Done |
| CityCorners | locations, favorites | Done |
| Taktik | clients, projects, timeEntries, tags, templates, settings | Done |
| Calc | calculations, savedFormulas | Done |
**Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless)

View file

@ -0,0 +1,59 @@
{
"name": "@calc/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --threshold error"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
},
"dependencies": {
"@calc/shared": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-stores": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/feedback": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/help": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/subscriptions": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,10 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-theme-ui/src/components";
@source "../../../../packages/shared-theme-ui/src/pages";

2
apps/calc/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const __BUILD_HASH__: string;
declare const __BUILD_TIME__: string;

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
import type { HandleClientError } from '@sveltejs/kit';
initErrorTracking({
serviceName: 'calc-web',
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
environment: import.meta.env.MODE,
});
export const handleError: HandleClientError = ({ error }) => {
handleSvelteError(error);
};

View file

@ -0,0 +1,28 @@
import type { Handle } from '@sveltejs/kit';
import { injectUmamiAnalytics } from '@manacore/shared-utils/analytics-server';
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
},
});
setSecurityHeaders(response, {
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
});
return response;
};

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
<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>
<div class="content-skeleton">
<div class="calc-placeholder">
<SkeletonBox width="360px" height="80px" borderRadius="12px" />
<div class="buttons-placeholder">
{#each Array(16) as _}
<SkeletonBox width="72px" height="56px" borderRadius="8px" />
{/each}
</div>
</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;
}
.calc-placeholder {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.buttons-placeholder {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
}
</style>

View file

@ -0,0 +1 @@
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';

View file

@ -0,0 +1,254 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
$props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="casio">
<div class="casio-body">
<!-- Brand header -->
<div class="casio-header">
<span class="casio-brand">CASIO</span>
<span class="casio-model">fx-82</span>
</div>
<!-- Solar panel strip -->
<div class="casio-solar">
{#each Array(8) as _}
<div class="casio-solar-cell"></div>
{/each}
</div>
<!-- LCD Display (green-gray) -->
<div class="casio-display">
<div class="casio-expression">{expression || ' '}</div>
<div class="casio-result" class:casio-error={!!error}>
{error || display}
</div>
</div>
<!-- Keypad -->
<div class="casio-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="casio-btn"
class:casio-btn-eq={btn === '='}
class:casio-btn-clear={btn === 'C'}
class:casio-btn-op={isOp(btn)}
class:casio-btn-num={!isOp(btn) && btn !== '=' && btn !== 'C'}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button class="casio-backspace" onclick={onBackspace}>DEL</button>
<!-- Footer -->
<div class="casio-footer">
<span>S-V.P.A.M.</span>
</div>
</div>
</div>
<style>
.casio {
display: flex;
justify-content: center;
}
.casio-body {
width: 310px;
background: linear-gradient(180deg, #e8e8e8 0%, #d0d0d0 30%, #c0c0c0 100%);
border-radius: 16px 16px 20px 20px;
padding: 16px 14px 20px;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 0 0 1px #aaa;
}
.casio-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
padding: 0 4px;
}
.casio-brand {
font-family: 'Arial', sans-serif;
font-size: 16px;
font-weight: bold;
color: #333;
letter-spacing: 2px;
}
.casio-model {
font-family: 'Arial', sans-serif;
font-size: 12px;
color: #666;
font-style: italic;
}
.casio-solar {
display: flex;
gap: 2px;
margin-bottom: 8px;
padding: 0 4px;
}
.casio-solar-cell {
flex: 1;
height: 8px;
background: linear-gradient(180deg, #2a2a4a, #1a1a3a);
border-radius: 1px;
}
.casio-display {
background: #b8c8a0;
border: 2px solid #8a9a70;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 14px;
min-height: 68px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.15);
}
.casio-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #3a4a2a;
opacity: 0.7;
min-height: 14px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.casio-result {
font-family: 'Courier New', monospace;
font-size: 28px;
font-weight: bold;
color: #1a2a0a;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 1px;
}
.casio-error {
color: #8a2020;
font-size: 16px;
}
.casio-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.casio-btn {
height: 44px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.08s;
position: relative;
top: 0;
}
.casio-btn:active {
top: 1px;
filter: brightness(0.9);
}
.casio-btn-num {
background: #f0f0f0;
color: #222;
box-shadow:
0 2px 0 #bbb,
0 3px 6px rgba(0, 0, 0, 0.15);
}
.casio-btn-op {
background: #e0e0e0;
color: #333;
box-shadow:
0 2px 0 #aaa,
0 3px 6px rgba(0, 0, 0, 0.15);
}
.casio-btn-eq {
background: #3366cc;
color: white;
font-size: 18px;
box-shadow:
0 2px 0 #2244aa,
0 3px 6px rgba(0, 0, 0, 0.2);
}
.casio-btn-clear {
background: #cc3333;
color: white;
box-shadow:
0 2px 0 #992222,
0 3px 6px rgba(0, 0, 0, 0.2);
}
.casio-backspace {
width: 100%;
margin-top: 6px;
height: 30px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 11px;
font-weight: 600;
cursor: pointer;
background: #d8d8d8;
color: #555;
box-shadow: 0 1px 0 #bbb;
}
.casio-backspace:hover {
background: #ccc;
}
.casio-footer {
text-align: right;
margin-top: 10px;
font-family: 'Arial', sans-serif;
font-size: 8px;
color: #999;
letter-spacing: 1px;
padding-right: 4px;
}
</style>

View file

@ -0,0 +1,233 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
$props();
// HP-35 had a distinctive layout - we adapt it for standard calc use
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="hp35">
<!-- Device frame -->
<div class="hp35-body">
<!-- HP Logo -->
<div class="hp35-logo">
<span class="hp35-hp">HP</span>
<span class="hp35-model">35</span>
</div>
<!-- LED Display (red on dark) -->
<div class="hp35-display">
<div class="hp35-expression">{expression || ' '}</div>
<div class="hp35-result" class:hp35-error={!!error}>
{error || display}
</div>
</div>
<!-- Keypad -->
<div class="hp35-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="hp35-btn"
class:hp35-btn-eq={btn === '='}
class:hp35-btn-clear={btn === 'C'}
class:hp35-btn-op={isOp(btn)}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<!-- Backspace -->
<button class="hp35-backspace" onclick={onBackspace}> CLR </button>
<!-- Footer -->
<div class="hp35-footer">
<span>HEWLETT · PACKARD</span>
</div>
</div>
</div>
<style>
.hp35 {
display: flex;
justify-content: center;
}
.hp35-body {
width: 320px;
background: linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border-radius: 20px 20px 24px 24px;
padding: 20px 16px 24px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 2px #0a0a1a;
position: relative;
}
.hp35-logo {
text-align: center;
margin-bottom: 12px;
font-family: 'Courier New', monospace;
}
.hp35-hp {
font-size: 18px;
font-weight: bold;
color: #c4c4c4;
letter-spacing: 4px;
}
.hp35-model {
font-size: 14px;
color: #888;
margin-left: 4px;
}
.hp35-display {
background: #0a0a0a;
border: 2px solid #333;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
min-height: 72px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8);
}
.hp35-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #ff3333;
opacity: 0.6;
min-height: 16px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hp35-result {
font-family: 'Courier New', monospace;
font-size: 28px;
font-weight: bold;
color: #ff2200;
text-align: right;
text-shadow: 0 0 12px rgba(255, 34, 0, 0.6);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 2px;
}
.hp35-error {
color: #ff6644;
font-size: 18px;
}
.hp35-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.hp35-btn {
height: 48px;
border: none;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.1s;
background: #2a2a4a;
color: #e0e0e0;
box-shadow:
0 3px 0 #1a1a30,
0 4px 8px rgba(0, 0, 0, 0.3);
position: relative;
top: 0;
}
.hp35-btn:active {
top: 2px;
box-shadow:
0 1px 0 #1a1a30,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.hp35-btn-eq {
background: #c63030;
color: white;
box-shadow:
0 3px 0 #8a2020,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.hp35-btn-eq:active {
box-shadow:
0 1px 0 #8a2020,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.hp35-btn-clear {
background: #4a3020;
color: #ff9966;
box-shadow:
0 3px 0 #2a1810,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.hp35-btn-op {
background: #3a3a5a;
color: #aaccff;
}
.hp35-backspace {
width: 100%;
margin-top: 8px;
height: 32px;
border: none;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 11px;
cursor: pointer;
background: #1a1a30;
color: #888;
letter-spacing: 1px;
}
.hp35-backspace:hover {
color: #aaa;
}
.hp35-footer {
text-align: center;
margin-top: 16px;
font-family: 'Courier New', monospace;
font-size: 9px;
color: #555;
letter-spacing: 3px;
text-transform: uppercase;
}
</style>

View file

@ -0,0 +1,148 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
$props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
</script>
<div class="minimal">
<!-- Display: just big text -->
<div class="minimal-display">
<div class="minimal-expression">{expression || ' '}</div>
<div class="minimal-result" class:minimal-error={!!error}>
{error || display}
</div>
</div>
<!-- Buttons: clean, borderless -->
<div class="minimal-grid">
{#each buttons as row}
{#each row as btn}
<button
class="minimal-btn"
class:minimal-btn-eq={btn === '='}
class:minimal-btn-clear={btn === 'C'}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button class="minimal-backspace" onclick={onBackspace}>←</button>
</div>
<style>
.minimal {
max-width: 300px;
margin: 0 auto;
}
.minimal-display {
padding: 24px 8px 16px;
text-align: right;
}
.minimal-expression {
font-family: system-ui, sans-serif;
font-size: 14px;
color: hsl(var(--muted-foreground));
opacity: 0.5;
min-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.minimal-result {
font-family: system-ui, sans-serif;
font-size: 48px;
font-weight: 200;
color: hsl(var(--foreground));
letter-spacing: -1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}
.minimal-error {
color: hsl(var(--destructive, 0 84% 60%));
font-size: 24px;
}
.minimal-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
}
.minimal-btn {
height: 56px;
border: none;
border-radius: 50%;
font-family: system-ui, sans-serif;
font-size: 20px;
font-weight: 300;
cursor: pointer;
transition: background 0.15s;
background: transparent;
color: hsl(var(--foreground));
}
.minimal-btn:hover {
background: hsl(var(--muted));
}
.minimal-btn:active {
background: hsl(var(--muted));
opacity: 0.7;
}
.minimal-btn-eq {
background: hsl(var(--foreground));
color: hsl(var(--background));
font-weight: 400;
}
.minimal-btn-eq:hover {
background: hsl(var(--foreground));
opacity: 0.8;
}
.minimal-btn-clear {
color: hsl(var(--destructive, 0 84% 60%));
font-weight: 400;
}
.minimal-backspace {
width: 100%;
margin-top: 4px;
height: 36px;
border: none;
border-radius: 18px;
font-size: 16px;
cursor: pointer;
background: transparent;
color: hsl(var(--muted-foreground));
}
.minimal-backspace:hover {
background: hsl(var(--muted));
}
</style>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
$props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function getButtonClass(btn: string): string {
if (btn === '=') return 'bg-pink-500 text-white hover:bg-pink-600 font-bold text-xl';
if (btn === 'C') return 'bg-red-500/20 text-red-400 hover:bg-red-500/30 font-bold';
if (['+', '-', '*', '/', '%', '(', ')'].includes(btn))
return 'bg-muted text-foreground hover:bg-muted/80 font-medium';
return 'bg-card text-foreground hover:bg-card/80 font-medium';
}
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
</script>
<div class="modern-skin">
<div class="display rounded-xl bg-card border border-border p-4 mb-4">
<div class="text-sm text-muted-foreground min-h-[1.5rem] font-mono truncate">
{expression || ' '}
</div>
<div
class="text-4xl font-bold text-foreground font-mono text-right tabular-nums truncate"
class:text-red-400={!!error}
>
{error || display}
</div>
</div>
<div class="grid grid-cols-4 gap-2">
{#each buttons as row}
{#each row as btn}
<button
class="h-14 rounded-xl border border-border transition-all active:scale-95 text-lg {getButtonClass(
btn
)}"
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button
class="mt-2 w-full h-10 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted transition-all text-sm"
onclick={onBackspace}
>
← Löschen
</button>
</div>

View file

@ -0,0 +1,251 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
$props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="ti84">
<div class="ti84-body">
<!-- Brand -->
<div class="ti84-header">
<span class="ti84-brand">TEXAS INSTRUMENTS</span>
<span class="ti84-model">TI-84 Plus</span>
</div>
<!-- Screen (blue LCD with pixel feel) -->
<div class="ti84-screen">
<div class="ti84-screen-inner">
<div class="ti84-expression">{expression || ' '}</div>
<div class="ti84-result" class:ti84-error={!!error}>
{error || display}
</div>
</div>
</div>
<!-- Navigation cluster -->
<div class="ti84-nav">
<button class="ti84-nav-btn" onclick={onBackspace}>DEL</button>
<button class="ti84-nav-btn ti84-nav-mode">2ND</button>
</div>
<!-- Keypad -->
<div class="ti84-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="ti84-btn"
class:ti84-btn-eq={btn === '='}
class:ti84-btn-clear={btn === 'C'}
class:ti84-btn-op={isOp(btn)}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<div class="ti84-footer">
<div class="ti84-usb"></div>
</div>
</div>
</div>
<style>
.ti84 {
display: flex;
justify-content: center;
}
.ti84-body {
width: 320px;
background: linear-gradient(180deg, #1a1a1a 0%, #222 50%, #1a1a1a 100%);
border-radius: 18px 18px 22px 22px;
padding: 16px;
box-shadow:
0 16px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 2px #111;
}
.ti84-header {
text-align: center;
margin-bottom: 10px;
}
.ti84-brand {
display: block;
font-family: 'Arial', sans-serif;
font-size: 9px;
color: #888;
letter-spacing: 3px;
text-transform: uppercase;
}
.ti84-model {
font-family: 'Arial', sans-serif;
font-size: 13px;
font-weight: bold;
color: #ccc;
letter-spacing: 1px;
}
.ti84-screen {
background: #1a2a1a;
border: 3px solid #333;
border-radius: 8px;
padding: 3px;
margin-bottom: 12px;
box-shadow:
inset 0 2px 10px rgba(0, 0, 0, 0.8),
0 1px 0 rgba(255, 255, 255, 0.05);
}
.ti84-screen-inner {
background: #2a4a3a;
border-radius: 4px;
padding: 12px 14px;
min-height: 72px;
}
.ti84-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #88cc88;
opacity: 0.7;
min-height: 14px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ti84-result {
font-family: 'Courier New', monospace;
font-size: 26px;
font-weight: bold;
color: #aaffaa;
text-align: right;
text-shadow: 0 0 8px rgba(170, 255, 170, 0.3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 2px;
}
.ti84-error {
color: #ffaa88;
font-size: 16px;
}
.ti84-nav {
display: flex;
gap: 6px;
margin-bottom: 10px;
justify-content: center;
}
.ti84-nav-btn {
padding: 4px 16px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 10px;
font-weight: 600;
cursor: pointer;
background: #444;
color: #ccc;
letter-spacing: 1px;
}
.ti84-nav-btn:hover {
background: #555;
}
.ti84-nav-mode {
background: #3366aa;
color: #ddeeff;
}
.ti84-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.ti84-btn {
height: 46px;
border: none;
border-radius: 6px;
font-family: 'Arial', sans-serif;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.08s;
background: #3a3a3a;
color: #e0e0e0;
box-shadow:
0 3px 0 #222,
0 4px 8px rgba(0, 0, 0, 0.3);
position: relative;
top: 0;
}
.ti84-btn:active {
top: 2px;
box-shadow:
0 1px 0 #222,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.ti84-btn-op {
background: #4a4a4a;
color: #aaccff;
}
.ti84-btn-eq {
background: #2255aa;
color: white;
font-size: 18px;
box-shadow:
0 3px 0 #153888,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.ti84-btn-clear {
background: #555;
color: #ff9966;
}
.ti84-footer {
display: flex;
justify-content: center;
margin-top: 12px;
}
.ti84-usb {
width: 20px;
height: 6px;
background: #333;
border-radius: 0 0 3px 3px;
}
</style>

View file

@ -0,0 +1,6 @@
export { default as ModernSkin } from './ModernSkin.svelte';
export { default as HP35Skin } from './HP35Skin.svelte';
export { default as CasioSkin } from './CasioSkin.svelte';
export { default as TI84Skin } from './TI84Skin.svelte';
export { default as MinimalSkin } from './MinimalSkin.svelte';
export type { CalcSkinProps } from './types';

View file

@ -0,0 +1,12 @@
/**
* Shared interface for all calculator skin components.
*/
export interface CalcSkinProps {
expression: string;
display: string;
error: string;
onButton: (btn: string) => void;
onClear: () => void;
onBackspace: () => void;
onEquals: () => void;
}

View file

@ -0,0 +1,28 @@
/**
* Guest seed data for the Calc app.
*
* Sample calculations loaded on first guest visit.
*/
import type { LocalCalculation } from './local-store';
export const guestCalculations: LocalCalculation[] = [
{
id: 'calc-demo-1',
mode: 'standard',
expression: '42 * 23',
result: '966',
},
{
id: 'calc-demo-2',
mode: 'scientific',
expression: 'sin(π/4)',
result: '0.7071067812',
},
{
id: 'calc-demo-3',
mode: 'standard',
expression: '1024 / 8',
result: '128',
},
];

View file

@ -0,0 +1,52 @@
/**
* Calc App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Calc data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestCalculations } from './guest-seed';
import type { CalculatorMode, CalculatorSkin } from '@calc/shared';
// ─── Types ──────────────────────────────────────────────────
export interface LocalCalculation extends BaseRecord {
mode: CalculatorMode;
expression: string;
result: string;
skin?: CalculatorSkin;
}
export interface LocalSavedFormula extends BaseRecord {
name: string;
expression: string;
description: string | null;
mode: CalculatorMode;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const calcStore = createLocalStore({
appId: 'calc',
collections: [
{
name: 'calculations',
indexes: ['mode'],
guestSeed: guestCalculations,
},
{
name: 'savedFormulas',
indexes: ['mode', 'name'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const calculationCollection = calcStore.collection<LocalCalculation>('calculations');
export const savedFormulaCollection = calcStore.collection<LocalSavedFormula>('savedFormulas');

View file

@ -0,0 +1,59 @@
/**
* Reactive Queries for Calc
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
calculationCollection,
savedFormulaCollection,
type LocalCalculation,
type LocalSavedFormula,
} from './local-store';
import type { Calculation, SavedFormula } from '@calc/shared';
// ─── Type Converters ───────────────────────────────────────
export function toCalculation(local: LocalCalculation): Calculation {
return {
id: local.id,
userId: 'local',
mode: local.mode,
expression: local.expression,
result: local.result,
skin: local.skin,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
return {
id: local.id,
userId: 'local',
name: local.name,
expression: local.expression,
description: local.description ?? undefined,
mode: local.mode,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks ──────────────────────────────────────
/** All calculations (history), newest first. */
export function useAllCalculations() {
return useLiveQueryWithDefault(async () => {
const locals = await calculationCollection.getAll();
return locals.map(toCalculation).reverse();
}, [] as Calculation[]);
}
/** All saved formulas. */
export function useAllSavedFormulas() {
return useLiveQueryWithDefault(async () => {
const locals = await savedFormulaCollection.getAll();
return locals.map(toSavedFormula);
}, [] as SavedFormula[]);
}

View file

@ -0,0 +1,261 @@
/**
* Safe math expression evaluator.
*
* Supports: +, -, *, /, %, ^, parentheses, and scientific functions.
* Does NOT use eval() parses manually for safety.
*/
const FUNCTIONS: Record<string, (x: number) => number> = {
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
log: Math.log10,
ln: Math.log,
sqrt: Math.sqrt,
cbrt: Math.cbrt,
abs: Math.abs,
ceil: Math.ceil,
floor: Math.floor,
round: Math.round,
exp: Math.exp,
};
const CONSTANTS: Record<string, number> = {
pi: Math.PI,
PI: Math.PI,
π: Math.PI,
e: Math.E,
E: Math.E,
φ: 1.6180339887,
phi: 1.6180339887,
};
type Token =
| { type: 'number'; value: number }
| { type: 'op'; value: string }
| { type: 'func'; value: string }
| { type: 'paren'; value: '(' | ')' };
function tokenize(expr: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const s = expr.replace(/\s+/g, '');
while (i < s.length) {
// Numbers (including decimals)
if (/[0-9.]/.test(s[i])) {
let num = '';
while (i < s.length && /[0-9.eE]/.test(s[i])) {
num += s[i++];
// Handle scientific notation sign
if ((s[i] === '+' || s[i] === '-') && /[eE]/.test(s[i - 1])) {
num += s[i++];
}
}
tokens.push({ type: 'number', value: parseFloat(num) });
continue;
}
// Parentheses
if (s[i] === '(' || s[i] === ')') {
tokens.push({ type: 'paren', value: s[i] as '(' | ')' });
i++;
continue;
}
// Operators
if ('+-*/%^'.includes(s[i])) {
// Handle unary minus
if (
s[i] === '-' &&
(tokens.length === 0 ||
tokens[tokens.length - 1].type === 'op' ||
(tokens[tokens.length - 1].type === 'paren' && tokens[tokens.length - 1].value === '('))
) {
let num = '-';
i++;
while (i < s.length && /[0-9.eE]/.test(s[i])) {
num += s[i++];
}
if (num.length > 1) {
tokens.push({ type: 'number', value: parseFloat(num) });
continue;
}
// It's just a minus, push as operator
tokens.push({ type: 'op', value: '-' });
continue;
}
tokens.push({ type: 'op', value: s[i] });
i++;
continue;
}
// Special characters (π, etc.)
if (s[i] === 'π' || s[i] === 'φ') {
tokens.push({ type: 'number', value: CONSTANTS[s[i]] });
i++;
continue;
}
// Functions and constants (letters)
if (/[a-zA-Z_]/.test(s[i])) {
let name = '';
while (i < s.length && /[a-zA-Z_0-9]/.test(s[i])) {
name += s[i++];
}
if (CONSTANTS[name] !== undefined) {
tokens.push({ type: 'number', value: CONSTANTS[name] });
} else if (FUNCTIONS[name]) {
tokens.push({ type: 'func', value: name });
} else {
throw new Error(`Unknown: ${name}`);
}
continue;
}
// Factorial
if (s[i] === '!') {
tokens.push({ type: 'op', value: '!' });
i++;
continue;
}
throw new Error(`Unexpected character: ${s[i]}`);
}
return tokens;
}
function precedence(op: string): number {
if (op === '+' || op === '-') return 1;
if (op === '*' || op === '/' || op === '%') return 2;
if (op === '^') return 3;
return 0;
}
function factorial(n: number): number {
if (n < 0 || !Number.isInteger(n)) throw new Error('Factorial of non-integer');
if (n > 170) return Infinity;
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}
function applyOp(op: string, a: number, b: number): number {
switch (op) {
case '+':
return a + b;
case '-':
return a - b;
case '*':
return a * b;
case '/':
if (b === 0) throw new Error('Division by zero');
return a / b;
case '%':
return a % b;
case '^':
return Math.pow(a, b);
default:
throw new Error(`Unknown op: ${op}`);
}
}
/**
* Evaluate a mathematical expression string.
* Returns the numeric result or throws on error.
*/
export function evaluate(expression: string): number {
const tokens = tokenize(expression);
const output: number[] = [];
const ops: Token[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'number') {
output.push(token.value);
} else if (token.type === 'func') {
ops.push(token);
} else if (token.type === 'op') {
if (token.value === '!') {
const val = output.pop();
if (val === undefined) throw new Error('Missing operand');
output.push(factorial(val));
} else {
while (
ops.length > 0 &&
ops[ops.length - 1].type === 'op' &&
precedence(ops[ops.length - 1].value as string) >= precedence(token.value)
) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
ops.push(token);
}
} else if (token.type === 'paren' && token.value === '(') {
ops.push(token);
} else if (token.type === 'paren' && token.value === ')') {
while (
ops.length > 0 &&
!(ops[ops.length - 1].type === 'paren' && ops[ops.length - 1].value === '(')
) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
ops.pop(); // remove '('
// If there's a function on the stack, apply it
if (ops.length > 0 && ops[ops.length - 1].type === 'func') {
const func = ops.pop()!;
const val = output.pop()!;
output.push(FUNCTIONS[func.value as string](val));
}
}
}
while (ops.length > 0) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
if (output.length !== 1) throw new Error('Invalid expression');
return output[0];
}
/**
* Format a number for display removes trailing zeros, handles very large/small numbers.
*/
export function formatResult(value: number, precision: number = 10): string {
if (!isFinite(value)) return value > 0 ? '∞' : '-∞';
if (isNaN(value)) return 'NaN';
// Use scientific notation for very large/small numbers
if (Math.abs(value) > 1e15 || (Math.abs(value) < 1e-10 && value !== 0)) {
return value.toExponential(precision - 1);
}
// Round to precision and strip trailing zeros
const result = parseFloat(value.toPrecision(precision));
return String(result);
}
/**
* Convert between number bases.
*/
export function convertBase(value: string, fromBase: number, toBase: number): string {
const decimal = parseInt(value, fromBase);
if (isNaN(decimal)) throw new Error('Invalid number');
return decimal.toString(toBase).toUpperCase();
}

View file

@ -0,0 +1,38 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('calc_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('calc_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,42 @@
{
"app": {
"name": "Calc",
"tagline": "Dein Taschenrechner-Hub"
},
"nav": {
"overview": "Übersicht",
"standard": "Standard",
"scientific": "Wissenschaftlich",
"programmer": "Programmierer",
"converter": "Einheiten",
"currency": "Währung",
"finance": "Finanzen",
"date": "Datum",
"percentage": "Prozent",
"settings": "Einstellungen"
},
"calc": {
"result": "Ergebnis",
"history": "Verlauf",
"clearHistory": "Verlauf löschen",
"noHistory": "Noch keine Berechnungen",
"error": "Fehler",
"copied": "Kopiert!",
"clear": "Löschen",
"equals": "Gleich"
},
"converter": {
"from": "Von",
"to": "Nach",
"swap": "Tauschen"
},
"finance": {
"principal": "Anfangskapital",
"rate": "Zinssatz",
"years": "Laufzeit (Jahre)",
"result": "Ergebnis",
"monthlyPayment": "Monatliche Rate",
"totalInterest": "Gesamtzinsen",
"totalAmount": "Gesamtbetrag"
}
}

View file

@ -0,0 +1,42 @@
{
"app": {
"name": "Calc",
"tagline": "Your Calculator Hub"
},
"nav": {
"overview": "Overview",
"standard": "Standard",
"scientific": "Scientific",
"programmer": "Programmer",
"converter": "Units",
"currency": "Currency",
"finance": "Finance",
"date": "Date",
"percentage": "Percent",
"settings": "Settings"
},
"calc": {
"result": "Result",
"history": "History",
"clearHistory": "Clear History",
"noHistory": "No calculations yet",
"error": "Error",
"copied": "Copied!",
"clear": "Clear",
"equals": "Equals"
},
"converter": {
"from": "From",
"to": "To",
"swap": "Swap"
},
"finance": {
"principal": "Principal",
"rate": "Interest Rate",
"years": "Term (Years)",
"result": "Result",
"monthlyPayment": "Monthly Payment",
"totalInterest": "Total Interest",
"totalAmount": "Total Amount"
}
}

View file

@ -0,0 +1,61 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
const calcOnboardingSteps: AppOnboardingStep[] = [
{
id: 'features',
type: 'info',
question: 'Willkommen bei Calc!',
description: 'Das kann Calc:',
emoji: '🧮',
gradient: { from: 'pink-500', to: 'pink-700' },
bullets: [
'Standard, Wissenschaftlich & Programmierer',
'Einheiten- & Währungsrechner',
'Finanzrechner (Zins, Kredit, Sparplan)',
'Historische Taschenrechner-Skins',
],
},
{
id: 'defaultMode',
type: 'select',
question: 'Welchen Modus nutzt du am häufigsten?',
description: 'Du kannst jederzeit wechseln.',
emoji: '🔢',
gradient: { from: 'pink-500', to: 'pink-700' },
options: [
{ id: 'standard', label: 'Standard', description: 'Grundrechenarten', emoji: '' },
{
id: 'scientific',
label: 'Wissenschaftlich',
description: 'sin, cos, log & mehr',
emoji: '🔬',
},
{ id: 'programmer', label: 'Programmierer', description: 'HEX, BIN, OCT', emoji: '💻' },
{ id: 'converter', label: 'Einheiten', description: 'Umrechnen leicht gemacht', emoji: '📏' },
],
defaultValue: 'standard',
},
{
id: 'welcome',
type: 'info',
question: 'Dein Rechner ist bereit!',
description: 'Tipps:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Tastatur-Eingabe funktioniert überall',
'Verlauf speichert alle Berechnungen',
'Wechsle Skins für verschiedene Looks',
'Drücke Cmd/Ctrl+K für Schnellzugriff',
],
},
];
export const calcOnboarding = createAppOnboardingStore({
appId: 'calc',
steps: calcOnboardingSteps,
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,29 @@
/**
* Calculation mutation store write operations only.
* Reads come from live query hooks in queries.ts.
*/
import { calculationCollection, type LocalCalculation } from '$lib/data/local-store';
import type { CreateCalculationInput } from '@calc/shared';
export const calculationsStore = {
async addCalculation(input: CreateCalculationInput) {
await calculationCollection.insert({
mode: input.mode,
expression: input.expression,
result: input.result,
skin: input.skin,
} as Omit<LocalCalculation, 'id'>);
},
async deleteCalculation(id: string) {
await calculationCollection.delete(id);
},
async clearHistory() {
const all = await calculationCollection.getAll();
for (const item of all) {
await calculationCollection.delete(item.id);
}
},
};

View file

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

View file

@ -0,0 +1,25 @@
/**
* Saved formula mutation store write operations only.
*/
import { savedFormulaCollection, type LocalSavedFormula } from '$lib/data/local-store';
import type { CreateFormulaInput, UpdateFormulaInput } from '@calc/shared';
export const savedFormulasStore = {
async saveFormula(input: CreateFormulaInput) {
await savedFormulaCollection.insert({
name: input.name,
expression: input.expression,
description: input.description ?? null,
mode: input.mode,
} as Omit<LocalSavedFormula, 'id'>);
},
async updateFormula(id: string, input: UpdateFormulaInput) {
await savedFormulaCollection.update(id, input);
},
async deleteFormula(id: string) {
await savedFormulaCollection.delete(id);
},
};

View file

@ -0,0 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'calc',
defaultVariant: 'lume',
});

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
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: 'calc',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,398 @@
<script lang="ts">
import { setContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { PillNavigation, CommandBar, TagStrip } from '@manacore/shared-ui';
import { SyncIndicator } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { useAllCalculations, useAllSavedFormulas } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { calcOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { calcStore } from '$lib/data/local-store';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
const allCalculations = useAllCalculations();
const allSavedFormulas = useAllSavedFormulas();
const allTags = useAllSharedTags();
setContext('calculations', allCalculations);
setContext('savedFormulas', allSavedFormulas);
setContext('tags', allTags);
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('calc')) {
showGuestWelcome = true;
}
}
const appItems = getPillAppItems('calc');
let { children } = $props();
let commandBarOpen = $state(false);
const commandBarQuickActions: QuickAction[] = [
{
id: 'standard',
label: 'Standard-Rechner',
icon: 'calculator',
href: '/standard',
shortcut: '1',
},
{
id: 'scientific',
label: 'Wissenschaftlich',
icon: 'flask',
href: '/scientific',
shortcut: '2',
},
{ id: 'programmer', label: 'Programmierer', icon: 'code', href: '/programmer', shortcut: '3' },
{
id: 'converter',
label: 'Einheiten-Rechner',
icon: 'ruler',
href: '/converter',
shortcut: '4',
},
{ id: 'currency', label: 'Währungsrechner', icon: 'coins', href: '/currency' },
{ id: 'finance', label: 'Finanzrechner', icon: 'piggy-bank', href: '/finance' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
const results: CommandBarItem[] = [];
const matchingCalcs = allCalculations.value
.filter((c) => c.expression.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((c) => ({
id: `calc-${c.id}`,
title: c.expression,
subtitle: `= ${c.result}`,
}));
results.push(...matchingCalcs);
const matchingFormulas = allSavedFormulas.value
.filter(
(f) =>
f.name.toLowerCase().includes(queryLower) ||
f.expression.toLowerCase().includes(queryLower)
)
.slice(0, 5)
.map((f) => ({
id: `formula-${f.id}`,
title: f.name,
subtitle: f.expression,
}));
results.push(...matchingFormulas);
return results.slice(0, 10);
}
function handleCommandBarSelect(item: CommandBarItem) {
if (item.id.startsWith('calc-') || item.id.startsWith('formula-')) {
goto('/standard');
}
}
let isCollapsed = $state(false);
let isDark = $derived(theme.isDark);
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
let themeVariantItems = $derived<PillDropdownItem[]>([
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
let isTagStripVisible = $state(false);
function handleTagStripToggle() {
isTagStripVisible = !isTagStripVisible;
}
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Übersicht', icon: 'home' },
{ href: '/standard', label: 'Standard', icon: 'calculator' },
{ href: '/scientific', label: 'Wissenschaftlich', icon: 'flask' },
{ href: '/programmer', label: 'Programmierer', icon: 'code' },
{ href: '/converter', label: 'Einheiten', icon: 'ruler' },
{ href: '/currency', label: 'Währung', icon: 'coins' },
{ href: '/finance', label: 'Finanzen', icon: 'piggy-bank' },
{ href: '/date', label: 'Datum', icon: 'calendar' },
{ href: '/percentage', label: 'Prozent', icon: 'percent' },
{ href: '/skins', label: 'Skins', icon: 'palette' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{
href: '/',
label: 'Tags',
icon: 'tag',
onClick: handleTagStripToggle,
active: isTagStripVisible,
},
];
const navItems = $derived(
filterHiddenNavItems('calc', baseNavItems, userSettings.nav?.hiddenNavItems || {})
);
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calc-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
async function handleAuthReady() {
await Promise.all([calcStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
const getToken = () => authStore.getValidToken();
calcStore.startSync(getToken);
tagMutations.startSync(getToken);
}
const savedCollapsed = localStorage.getItem('calc-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
initGuestWelcome();
if (authStore.isAuthenticated) {
await userSettings.load();
}
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Calc"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#ec4899"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
/>
{#if isTagStripVisible}
<TagStrip
tags={allTags.value.map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
managementHref="/tags"
/>
{/if}
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
{#if calcOnboarding.shouldShow}
<MiniOnboardingModal store={calcOnboarding} appName="Calc" appEmoji="🧮" />
{/if}
<GuestWelcomeModal
appId="calc"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<SyncIndicator />
</AuthGate>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
position: relative;
z-index: 0;
padding-bottom: 100px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Calculator,
FlaskConical,
Code,
Ruler,
Coins,
PiggyBank,
Calendar,
Percent,
} from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let isLoading = $state(true);
onMount(async () => {
isLoading = false;
});
const quickLinks = [
{
href: '/standard',
icon: Calculator,
label: 'Standard',
description: 'Grundrechenarten',
color: 'bg-pink-500',
},
{
href: '/scientific',
icon: FlaskConical,
label: 'Wissenschaftlich',
description: 'sin, cos, log & mehr',
color: 'bg-violet-500',
},
{
href: '/programmer',
icon: Code,
label: 'Programmierer',
description: 'HEX, BIN, OCT',
color: 'bg-cyan-500',
},
{
href: '/converter',
icon: Ruler,
label: 'Einheiten',
description: 'Umrechnen',
color: 'bg-emerald-500',
},
{
href: '/currency',
icon: Coins,
label: 'Währung',
description: 'Wechselkurse',
color: 'bg-amber-500',
},
{
href: '/finance',
icon: PiggyBank,
label: 'Finanzen',
description: 'Zins & Kredit',
color: 'bg-blue-500',
},
{
href: '/date',
icon: Calendar,
label: 'Datum',
description: 'Tage berechnen',
color: 'bg-orange-500',
},
{
href: '/percentage',
icon: Percent,
label: 'Prozent & Trinkgeld',
description: 'Aufteilen & Berechnen',
color: 'bg-rose-500',
},
];
</script>
<svelte:head>
<title>Calc - Dashboard</title>
</svelte:head>
{#if isLoading}
<AppLoadingSkeleton />
{:else}
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Calc</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Taschenrechner-Hub</p>
</header>
<!-- Quick display -->
<div class="mb-8 p-6 rounded-xl bg-card border border-border">
<div class="flex items-center gap-4">
<div class="p-3 rounded-full bg-primary/10">
<Calculator size={32} class="text-primary" />
</div>
<div>
<div class="text-4xl font-bold text-foreground tabular-nums font-mono">0</div>
<div class="text-muted-foreground text-sm">Wähle einen Rechner-Modus</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each quickLinks as link}
<a
href={link.href}
class="quick-link p-4 rounded-xl bg-card border border-border hover:border-primary/50 transition-all hover:shadow-lg group"
>
<div class="flex flex-col items-center text-center gap-3">
<div
class="{link.color} p-3 rounded-full text-white group-hover:scale-110 transition-transform"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
<style>
.dashboard {
max-width: 800px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { UNIT_CATEGORIES, UNITS_BY_CATEGORY } from '@calc/shared/constants';
import type { UnitCategory, UnitDefinition } from '@calc/shared';
let selectedCategory = $state<UnitCategory>('length');
let fromUnit = $state('m');
let toUnit = $state('km');
let fromValue = $state('1');
let units = $derived(UNITS_BY_CATEGORY[selectedCategory] || []);
let result = $derived(() => {
const val = parseFloat(fromValue);
if (isNaN(val)) return '';
const from = units.find((u: UnitDefinition) => u.id === fromUnit);
const to = units.find((u: UnitDefinition) => u.id === toUnit);
if (!from || !to) return '';
const baseValue = from.toBase(val);
const converted = to.fromBase(baseValue);
// Format nicely
if (Math.abs(converted) < 0.001 && converted !== 0) return converted.toExponential(4);
if (Math.abs(converted) > 1e12) return converted.toExponential(4);
return parseFloat(converted.toPrecision(10)).toString();
});
function swapUnits() {
const tmp = fromUnit;
fromUnit = toUnit;
toUnit = tmp;
}
function selectCategory(cat: UnitCategory) {
selectedCategory = cat;
const newUnits = UNITS_BY_CATEGORY[cat] || [];
fromUnit = newUnits[0]?.id || '';
toUnit = newUnits[1]?.id || newUnits[0]?.id || '';
}
</script>
<svelte:head>
<title>Calc - Einheiten</title>
</svelte:head>
<div class="converter-page">
<!-- Category pills -->
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
{#each UNIT_CATEGORIES.filter((c) => UNITS_BY_CATEGORY[c.id]) as cat}
<button
class="shrink-0 px-3 py-1.5 rounded-full text-sm transition-all border
{selectedCategory === cat.id
? 'bg-emerald-500 text-white border-emerald-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => selectCategory(cat.id)}
>
{cat.label.de}
</button>
{/each}
</div>
<!-- Converter Card -->
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
<!-- From -->
<div>
<label class="text-xs text-muted-foreground mb-1 block">Von</label>
<div class="flex gap-2">
<input
type="text"
inputmode="decimal"
bind:value={fromValue}
class="flex-1 h-12 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-xl focus:outline-none focus:border-primary"
/>
<select
bind:value={fromUnit}
class="h-12 px-3 rounded-lg bg-background border border-border text-foreground"
>
{#each units as unit}
<option value={unit.id}>{unit.symbol} {unit.name.de}</option>
{/each}
</select>
</div>
</div>
<!-- Swap button -->
<div class="flex justify-center">
<button
class="p-2 rounded-full bg-muted hover:bg-muted/80 transition-colors text-muted-foreground"
onclick={swapUnits}
>
</button>
</div>
<!-- To -->
<div>
<label class="text-xs text-muted-foreground mb-1 block">Nach</label>
<div class="flex gap-2">
<div
class="flex-1 h-12 px-3 rounded-lg bg-muted/30 border border-border flex items-center font-mono text-xl text-foreground"
>
{result()}
</div>
<select
bind:value={toUnit}
class="h-12 px-3 rounded-lg bg-background border border-border text-foreground"
>
{#each units as unit}
<option value={unit.id}>{unit.symbol} {unit.name.de}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Quick reference -->
{#if fromValue && result()}
<div
class="mt-4 p-3 rounded-lg bg-muted/30 text-sm text-muted-foreground text-center font-mono"
>
{fromValue}
{units.find((u: UnitDefinition) => u.id === fromUnit)?.symbol} = {result()}
{units.find((u: UnitDefinition) => u.id === toUnit)?.symbol}
</div>
{/if}
</div>
<style>
.converter-page {
max-width: 500px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,184 @@
<script lang="ts">
let amount = $state(100);
let fromCurrency = $state('EUR');
let toCurrency = $state('USD');
let rates = $state<Record<string, number>>({});
let loading = $state(false);
let lastUpdated = $state('');
const currencies = [
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'GBP', name: 'Brit. Pfund', symbol: '£' },
{ code: 'CHF', name: 'Schweizer Franken', symbol: 'CHF' },
{ code: 'JPY', name: 'Japanischer Yen', symbol: '¥' },
{ code: 'CNY', name: 'Chinesischer Yuan', symbol: '¥' },
{ code: 'CAD', name: 'Kanadischer Dollar', symbol: 'C$' },
{ code: 'AUD', name: 'Australischer Dollar', symbol: 'A$' },
{ code: 'SEK', name: 'Schwedische Krone', symbol: 'kr' },
{ code: 'NOK', name: 'Norwegische Krone', symbol: 'kr' },
{ code: 'DKK', name: 'Dänische Krone', symbol: 'kr' },
{ code: 'PLN', name: 'Polnischer Zloty', symbol: 'zł' },
{ code: 'CZK', name: 'Tschechische Krone', symbol: 'Kč' },
{ code: 'TRY', name: 'Türkische Lira', symbol: '₺' },
{ code: 'INR', name: 'Indische Rupie', symbol: '₹' },
{ code: 'BRL', name: 'Brasilianischer Real', symbol: 'R$' },
{ code: 'KRW', name: 'Südkoreanischer Won', symbol: '₩' },
];
// Static fallback rates (EUR-based, approximate)
const FALLBACK_RATES: Record<string, number> = {
EUR: 1,
USD: 1.08,
GBP: 0.86,
CHF: 0.95,
JPY: 162.5,
CNY: 7.85,
CAD: 1.47,
AUD: 1.66,
SEK: 11.2,
NOK: 11.5,
DKK: 7.46,
PLN: 4.32,
CZK: 25.3,
TRY: 34.8,
INR: 90.5,
BRL: 5.35,
KRW: 1420,
};
async function fetchRates() {
loading = true;
try {
// Try free API first
const res = await fetch(`https://open.er-api.com/v6/latest/${fromCurrency}`);
if (res.ok) {
const data = await res.json();
rates = data.rates || {};
lastUpdated = data.time_last_update_utc || '';
} else {
throw new Error('API unavailable');
}
} catch {
// Fallback to static rates
const fromRate = FALLBACK_RATES[fromCurrency] || 1;
const converted: Record<string, number> = {};
for (const [code, rate] of Object.entries(FALLBACK_RATES)) {
converted[code] = rate / fromRate;
}
rates = converted;
lastUpdated = 'Offline-Kurse (Richtwerte)';
}
loading = false;
}
// Fetch on mount and when fromCurrency changes
$effect(() => {
fetchRates();
});
let result = $derived(() => {
const rate = rates[toCurrency];
if (!rate) return null;
return amount * rate;
});
function swapCurrencies() {
const tmp = fromCurrency;
fromCurrency = toCurrency;
toCurrency = tmp;
}
function fmt(n: number): string {
return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
</script>
<svelte:head>
<title>Calc - Währung</title>
</svelte:head>
<div class="currency-page">
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
<h2 class="text-lg font-bold text-foreground">Währungsrechner</h2>
<div>
<label class="text-xs text-muted-foreground mb-1 block">Betrag</label>
<input
type="number"
bind:value={amount}
class="w-full h-12 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-xl"
/>
</div>
<div class="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
<label class="block">
<span class="text-xs text-muted-foreground">Von</span>
<select
bind:value={fromCurrency}
class="mt-1 w-full h-10 px-2 rounded-lg bg-background border border-border text-foreground text-sm"
>
{#each currencies as c}
<option value={c.code}>{c.code} {c.name}</option>
{/each}
</select>
</label>
<button
class="h-10 px-3 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground"
onclick={swapCurrencies}>↔</button
>
<label class="block">
<span class="text-xs text-muted-foreground">Nach</span>
<select
bind:value={toCurrency}
class="mt-1 w-full h-10 px-2 rounded-lg bg-background border border-border text-foreground text-sm"
>
{#each currencies as c}
<option value={c.code}>{c.code} {c.name}</option>
{/each}
</select>
</label>
</div>
{#if loading}
<div class="text-center text-muted-foreground text-sm py-4">Kurse laden...</div>
{:else if result() !== null}
<div class="pt-4 border-t border-border text-center">
<div class="text-3xl font-bold font-mono text-foreground">
{fmt(result()!)}
{toCurrency}
</div>
<div class="text-sm text-muted-foreground mt-1">
1 {fromCurrency} = {(rates[toCurrency] || 0).toFixed(4)}
{toCurrency}
</div>
</div>
{/if}
{#if lastUpdated}
<div class="text-xs text-muted-foreground/60 text-center">{lastUpdated}</div>
{/if}
</div>
<!-- Quick conversions -->
{#if Object.keys(rates).length > 0}
<div class="mt-6 p-4 rounded-xl bg-card border border-border">
<h3 class="text-sm font-medium text-muted-foreground mb-3">Schnellübersicht</h3>
<div class="grid grid-cols-2 gap-2">
{#each currencies.filter((c) => c.code !== fromCurrency).slice(0, 8) as c}
<div class="flex justify-between p-2 rounded-lg bg-muted/30 text-sm">
<span class="text-muted-foreground">{c.code}</span>
<span class="font-mono text-foreground">{fmt(amount * (rates[c.code] || 0))}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.currency-page {
max-width: 500px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,117 @@
<script lang="ts">
let date1 = $state(new Date().toISOString().split('T')[0]);
let date2 = $state('');
let addDays = $state(0);
let daysBetween = $derived(() => {
if (!date1 || !date2) return null;
const d1 = new Date(date1);
const d2 = new Date(date2);
const diff = Math.abs(d2.getTime() - d1.getTime());
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
const weeks = Math.floor(days / 7);
const months = Math.round(days / 30.44);
return { days, weeks, months };
});
let addedDate = $derived(() => {
if (!date1 || !addDays) return null;
const d = new Date(date1);
d.setDate(d.getDate() + addDays);
return d;
});
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Calc - Datum</title>
</svelte:head>
<div class="date-page">
<!-- Days between dates -->
<div class="p-6 rounded-xl bg-card border border-border space-y-4 mb-6">
<h2 class="text-lg font-bold text-foreground">Tage zwischen Daten</h2>
<div class="grid grid-cols-2 gap-4">
<label class="block">
<span class="text-xs text-muted-foreground">Von</span>
<input
type="date"
bind:value={date1}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Bis</span>
<input
type="date"
bind:value={date2}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
/>
</label>
</div>
{#if daysBetween()}
<div class="pt-4 border-t border-border">
<div class="text-3xl font-bold text-foreground font-mono text-center">
{daysBetween()?.days} Tage
</div>
<div class="text-sm text-muted-foreground text-center mt-1">
= {daysBetween()?.weeks} Wochen = ~{daysBetween()?.months} Monate
</div>
</div>
{/if}
</div>
<!-- Add/subtract days -->
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
<h2 class="text-lg font-bold text-foreground">Tage addieren/subtrahieren</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Startdatum</span>
<input
type="date"
bind:value={date1}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Tage (+/-)</span>
<input
type="number"
bind:value={addDays}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="flex gap-2">
{#each [7, 14, 30, 90, 365] as days}
<button
class="flex-1 py-1.5 rounded-lg text-xs transition-all border {addDays === days
? 'bg-orange-500 text-white border-orange-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (addDays = days)}>+{days}</button
>
{/each}
</div>
{#if addedDate()}
<div class="pt-4 border-t border-border text-center">
<div class="text-lg font-bold text-foreground">{formatDate(addedDate()!)}</div>
<div class="text-xs text-muted-foreground mt-1">
{addedDate()!.toISOString().split('T')[0]}
</div>
</div>
{/if}
</div>
</div>
<style>
.date-page {
max-width: 500px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,312 @@
<script lang="ts">
import type { FinanceMode } from '@calc/shared';
let mode = $state<FinanceMode>('compound-interest');
// Compound Interest
let principal = $state(10000);
let rate = $state(5);
let years = $state(10);
let compoundsPerYear = $state(12);
// Loan
let loanAmount = $state(200000);
let loanRate = $state(3.5);
let loanYears = $state(25);
// Savings
let monthlyDeposit = $state(200);
let savingsRate = $state(5);
let savingsYears = $state(20);
let initialDeposit = $state(1000);
// Tip
let billAmount = $state(50);
let tipPercent = $state(15);
let splitCount = $state(2);
// Compound Interest Result
let compoundResult = $derived(() => {
const r = rate / 100 / compoundsPerYear;
const n = compoundsPerYear * years;
const total = principal * Math.pow(1 + r, n);
return { total, interest: total - principal };
});
// Loan Result
let loanResult = $derived(() => {
const r = loanRate / 100 / 12;
const n = loanYears * 12;
const monthly = (loanAmount * (r * Math.pow(1 + r, n))) / (Math.pow(1 + r, n) - 1);
const total = monthly * n;
return { monthly, total, interest: total - loanAmount };
});
// Savings Result
let savingsResult = $derived(() => {
const r = savingsRate / 100 / 12;
const n = savingsYears * 12;
const futureValue =
initialDeposit * Math.pow(1 + r, n) + monthlyDeposit * ((Math.pow(1 + r, n) - 1) / r);
const totalDeposited = initialDeposit + monthlyDeposit * n;
return {
total: futureValue,
deposited: totalDeposited,
interest: futureValue - totalDeposited,
};
});
// Tip Result
let tipResult = $derived(() => {
const tip = (billAmount * tipPercent) / 100;
const total = billAmount + tip;
const perPerson = total / splitCount;
return { tip, total, perPerson };
});
function fmt(n: number): string {
return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const modes: { id: FinanceMode; label: string }[] = [
{ id: 'compound-interest', label: 'Zinseszins' },
{ id: 'loan', label: 'Kredit' },
{ id: 'savings', label: 'Sparplan' },
{ id: 'tip', label: 'Trinkgeld' },
];
</script>
<svelte:head>
<title>Calc - Finanzen</title>
</svelte:head>
<div class="finance-page">
<!-- Mode tabs -->
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
{#each modes as m}
<button
class="shrink-0 px-3 py-1.5 rounded-full text-sm transition-all border
{mode === m.id
? 'bg-blue-500 text-white border-blue-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (mode = m.id)}
>
{m.label}
</button>
{/each}
</div>
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
{#if mode === 'compound-interest'}
<h2 class="text-lg font-bold text-foreground">Zinseszinsrechner</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Anfangskapital (€)</span>
<input
type="number"
bind:value={principal}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
<input
type="number"
step="0.1"
bind:value={rate}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
<input
type="number"
bind:value={years}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Zinsperioden/Jahr</span>
<select
bind:value={compoundsPerYear}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
>
<option value={1}>Jährlich</option>
<option value={4}>Vierteljährlich</option>
<option value={12}>Monatlich</option>
<option value={365}>Täglich</option>
</select>
</label>
<div class="pt-4 border-t border-border space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Endkapital</span><span
class="font-bold font-mono text-foreground">{fmt(compoundResult().total)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Zinsen</span><span class="font-mono text-emerald-500"
>{fmt(compoundResult().interest)}</span
>
</div>
</div>
{:else if mode === 'loan'}
<h2 class="text-lg font-bold text-foreground">Kreditrechner</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Darlehensbetrag (€)</span>
<input
type="number"
bind:value={loanAmount}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
<input
type="number"
step="0.1"
bind:value={loanRate}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
<input
type="number"
bind:value={loanYears}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="pt-4 border-t border-border space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Monatliche Rate</span><span
class="font-bold font-mono text-foreground">{fmt(loanResult().monthly)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Gesamtkosten</span><span
class="font-mono text-foreground">{fmt(loanResult().total)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Zinskosten</span><span class="font-mono text-red-400"
>{fmt(loanResult().interest)}</span
>
</div>
</div>
{:else if mode === 'savings'}
<h2 class="text-lg font-bold text-foreground">Sparplanrechner</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Anfangseinlage (€)</span>
<input
type="number"
bind:value={initialDeposit}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Monatliche Sparrate (€)</span>
<input
type="number"
bind:value={monthlyDeposit}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
<input
type="number"
step="0.1"
bind:value={savingsRate}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
<input
type="number"
bind:value={savingsYears}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="pt-4 border-t border-border space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Endkapital</span><span
class="font-bold font-mono text-foreground">{fmt(savingsResult().total)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Eingezahlt</span><span
class="font-mono text-foreground">{fmt(savingsResult().deposited)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Zinsen</span><span class="font-mono text-emerald-500"
>{fmt(savingsResult().interest)}</span
>
</div>
</div>
{:else if mode === 'tip'}
<h2 class="text-lg font-bold text-foreground">Trinkgeld & Split</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Rechnungsbetrag (€)</span>
<input
type="number"
bind:value={billAmount}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Trinkgeld (%)</span>
<div class="flex gap-2 mt-1">
{#each [10, 15, 20, 25] as pct}
<button
class="flex-1 h-10 rounded-lg text-sm transition-all border {tipPercent === pct
? 'bg-amber-500 text-white border-amber-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (tipPercent = pct)}>{pct}%</button
>
{/each}
</div>
<input
type="number"
bind:value={tipPercent}
class="mt-2 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Aufteilen auf (Personen)</span>
<input
type="number"
min="1"
bind:value={splitCount}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="pt-4 border-t border-border space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Trinkgeld</span><span
class="font-mono text-foreground">{fmt(tipResult().tip)}</span
>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Gesamt</span><span
class="font-bold font-mono text-foreground">{fmt(tipResult().total)}</span
>
</div>
{#if splitCount > 1}
<div class="flex justify-between">
<span class="text-muted-foreground">Pro Person</span><span
class="font-bold font-mono text-amber-500">{fmt(tipResult().perPerson)}</span
>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.finance-page {
max-width: 500px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,176 @@
<script lang="ts">
// Percentage calculator modes
let mode = $state<'of' | 'change' | 'markup' | 'discount'>('of');
// X% of Y
let percentValue = $state(15);
let baseValue = $state(200);
// Percentage change
let oldValue = $state(100);
let newValue = $state(125);
// Markup/Discount
let price = $state(100);
let percentChange = $state(20);
let percentOfResult = $derived((baseValue * percentValue) / 100);
let changeResult = $derived(() => {
if (oldValue === 0) return { percent: 0, diff: 0 };
const diff = newValue - oldValue;
const percent = (diff / oldValue) * 100;
return { percent, diff };
});
let markupResult = $derived(price * (1 + percentChange / 100));
let discountResult = $derived(price * (1 - percentChange / 100));
function fmt(n: number): string {
return parseFloat(n.toPrecision(10)).toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
const modes: { id: typeof mode; label: string }[] = [
{ id: 'of', label: 'X% von Y' },
{ id: 'change', label: 'Änderung' },
{ id: 'markup', label: 'Aufschlag' },
{ id: 'discount', label: 'Rabatt' },
];
</script>
<svelte:head>
<title>Calc - Prozent</title>
</svelte:head>
<div class="percent-page">
<div class="flex gap-2 mb-6">
{#each modes as m}
<button
class="flex-1 py-1.5 rounded-full text-sm transition-all border
{mode === m.id
? 'bg-rose-500 text-white border-rose-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (mode = m.id)}>{m.label}</button
>
{/each}
</div>
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
{#if mode === 'of'}
<h2 class="text-lg font-bold text-foreground">X% von Y</h2>
<div class="flex items-center gap-2">
<input
type="number"
bind:value={percentValue}
class="w-20 h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-center"
/>
<span class="text-muted-foreground">% von</span>
<input
type="number"
bind:value={baseValue}
class="flex-1 h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</div>
<div class="pt-4 border-t border-border text-center">
<div class="text-3xl font-bold font-mono text-foreground">{fmt(percentOfResult)}</div>
<div class="text-sm text-muted-foreground">
{percentValue}% von {baseValue} = {fmt(percentOfResult)}
</div>
</div>
{:else if mode === 'change'}
<h2 class="text-lg font-bold text-foreground">Prozentuale Änderung</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Alter Wert</span>
<input
type="number"
bind:value={oldValue}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Neuer Wert</span>
<input
type="number"
bind:value={newValue}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="pt-4 border-t border-border text-center">
<div
class="text-3xl font-bold font-mono {changeResult().percent >= 0
? 'text-emerald-500'
: 'text-red-400'}"
>
{changeResult().percent >= 0 ? '+' : ''}{fmt(changeResult().percent)}%
</div>
<div class="text-sm text-muted-foreground">Differenz: {fmt(changeResult().diff)}</div>
</div>
{:else if mode === 'markup'}
<h2 class="text-lg font-bold text-foreground">Preisaufschlag</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Preis (€)</span>
<input
type="number"
bind:value={price}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Aufschlag (%)</span>
<input
type="number"
bind:value={percentChange}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="pt-4 border-t border-border text-center">
<div class="text-3xl font-bold font-mono text-foreground">{fmt(markupResult)}</div>
<div class="text-sm text-muted-foreground">
{fmt(price)} + {percentChange}% = {fmt(markupResult)}
</div>
</div>
{:else if mode === 'discount'}
<h2 class="text-lg font-bold text-foreground">Rabatt</h2>
<label class="block">
<span class="text-xs text-muted-foreground">Originalpreis (€)</span>
<input
type="number"
bind:value={price}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<label class="block">
<span class="text-xs text-muted-foreground">Rabatt (%)</span>
<input
type="number"
bind:value={percentChange}
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
/>
</label>
<div class="flex gap-2 mt-1">
{#each [10, 15, 20, 25, 50] as pct}
<button
class="flex-1 py-1 rounded text-xs border {percentChange === pct
? 'bg-rose-500 text-white border-rose-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (percentChange = pct)}>{pct}%</button
>
{/each}
</div>
<div class="pt-4 border-t border-border text-center">
<div class="text-3xl font-bold font-mono text-foreground">{fmt(discountResult)}</div>
<div class="text-sm text-muted-foreground">Ersparnis: {fmt(price - discountResult)}</div>
</div>
{/if}
</div>
</div>
<style>
.percent-page {
max-width: 500px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { convertBase } from '$lib/engine/evaluate';
import type { NumberBase } from '@calc/shared';
let inputValue = $state('0');
let activeBase = $state<NumberBase>('dec');
let error = $state('');
const bases: { id: NumberBase; label: string; radix: number }[] = [
{ id: 'hex', label: 'HEX', radix: 16 },
{ id: 'dec', label: 'DEC', radix: 10 },
{ id: 'oct', label: 'OCT', radix: 8 },
{ id: 'bin', label: 'BIN', radix: 2 },
];
function getRadix(base: NumberBase): number {
return bases.find((b) => b.id === base)!.radix;
}
function getConverted(targetBase: NumberBase): string {
if (!inputValue || inputValue === '0') return '0';
try {
return convertBase(inputValue, getRadix(activeBase), getRadix(targetBase));
} catch {
return '—';
}
}
function appendDigit(digit: string) {
error = '';
if (inputValue === '0') {
inputValue = digit;
} else {
inputValue += digit;
}
}
function clear() {
inputValue = '0';
error = '';
}
function backspace() {
inputValue = inputValue.length > 1 ? inputValue.slice(0, -1) : '0';
}
function switchBase(newBase: NumberBase) {
try {
if (inputValue !== '0') {
inputValue = convertBase(inputValue, getRadix(activeBase), getRadix(newBase));
}
} catch {
inputValue = '0';
}
activeBase = newBase;
}
// Available digits per base
function isDigitValid(digit: string): boolean {
const val = parseInt(digit, 16);
return val < getRadix(activeBase);
}
const hexDigits = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
];
</script>
<svelte:head>
<title>Calc - Programmierer</title>
</svelte:head>
<div class="programmer-page">
<!-- Base selector -->
<div class="flex gap-2 mb-4">
{#each bases as base}
<button
class="flex-1 py-2 rounded-lg text-sm font-medium transition-all border {activeBase ===
base.id
? 'bg-cyan-500 text-white border-cyan-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => switchBase(base.id)}
>
{base.label}
</button>
{/each}
</div>
<!-- Display all bases -->
<div class="space-y-1 mb-6 p-4 rounded-xl bg-card border border-border">
{#each bases as base}
<div class="flex items-center gap-3 py-1">
<span class="text-xs font-medium text-muted-foreground w-8">{base.label}</span>
<span
class="font-mono text-sm flex-1 truncate {activeBase === base.id
? 'text-foreground font-bold text-lg'
: 'text-muted-foreground'}"
>
{activeBase === base.id ? inputValue : getConverted(base.id)}
</span>
</div>
{/each}
{#if error}
<div class="text-red-400 text-xs mt-1">{error}</div>
{/if}
</div>
<!-- Keypad -->
<div class="grid grid-cols-4 gap-2">
{#each hexDigits as digit}
<button
class="h-12 rounded-lg border border-border font-mono text-sm transition-all active:scale-95
{isDigitValid(digit)
? 'bg-card text-foreground hover:bg-muted cursor-pointer'
: 'bg-muted/30 text-muted-foreground/30 cursor-not-allowed'}"
onclick={() => isDigitValid(digit) && appendDigit(digit)}
disabled={!isDigitValid(digit)}
>
{digit}
</button>
{/each}
</div>
<div class="flex gap-2 mt-3">
<button
class="flex-1 h-10 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30"
onclick={clear}>C</button
>
<button
class="flex-1 h-10 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted"
onclick={backspace}>← DEL</button
>
</div>
<!-- Bit info -->
{#if activeBase === 'dec' && inputValue !== '0'}
{@const num = parseInt(inputValue, 10)}
{#if !isNaN(num)}
<div class="mt-4 p-3 rounded-lg bg-muted/30 text-xs text-muted-foreground">
<div class="font-mono">
{num} = 0x{num.toString(16).toUpperCase()} = 0b{num.toString(2)}
</div>
<div class="mt-1">
Bits: {num.toString(2).length} | Bytes: {Math.ceil(num.toString(2).length / 8)}
</div>
</div>
{/if}
{/if}
</div>
<style>
.programmer-page {
max-width: 400px;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,211 @@
<script lang="ts">
import { getContext } from 'svelte';
import { evaluate, formatResult } from '$lib/engine/evaluate';
import { calculationsStore } from '$lib/stores/calculations.svelte';
import { SCIENTIFIC_CONSTANTS } from '@calc/shared/constants';
import type { Calculation } from '@calc/shared';
const allCalculations = getContext<{ value: Calculation[] }>('calculations');
let expression = $state('');
let display = $state('0');
let hasResult = $state(false);
let error = $state('');
let angleMode = $state<'deg' | 'rad'>('rad');
function append(char: string) {
if (hasResult && /[0-9.]/.test(char)) {
expression = '';
display = '';
hasResult = false;
} else if (hasResult) {
expression = display;
hasResult = false;
}
error = '';
expression += char;
display = expression;
}
function clear() {
expression = '';
display = '0';
hasResult = false;
error = '';
}
function backspace() {
if (hasResult) {
clear();
return;
}
expression = expression.slice(0, -1);
display = expression || '0';
}
async function calculate() {
if (!expression.trim()) return;
try {
const result = evaluate(expression);
const formatted = formatResult(result);
await calculationsStore.addCalculation({
mode: 'scientific',
expression,
result: formatted,
});
display = formatted;
hasResult = true;
error = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
function handleButton(btn: string) {
if (btn === 'C') clear();
else if (btn === '=') calculate();
else if (btn === 'DEL') backspace();
else append(btn);
}
let recentHistory = $derived(
allCalculations.value.filter((c) => c.mode === 'scientific').slice(0, 8)
);
const sciButtons = [
['sin(', 'cos(', 'tan(', 'π'],
['asin(', 'acos(', 'atan(', 'e'],
['log(', 'ln(', 'sqrt(', '^'],
['(', ')', '!', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function getButtonClass(btn: string): string {
if (btn === '=') return 'bg-pink-500 text-white hover:bg-pink-600 font-bold';
if (['sin(', 'cos(', 'tan(', 'asin(', 'acos(', 'atan(', 'log(', 'ln(', 'sqrt('].includes(btn))
return 'bg-violet-500/20 text-violet-400 hover:bg-violet-500/30 text-xs';
if (['π', 'e', '^', '!', '%'].includes(btn))
return 'bg-muted text-foreground hover:bg-muted/80';
if (['+', '-', '*', '/', '(', ')'].includes(btn))
return 'bg-muted text-foreground hover:bg-muted/80';
return 'bg-card text-foreground hover:bg-card/80';
}
</script>
<svelte:head>
<title>Calc - Wissenschaftlich</title>
</svelte:head>
<div class="scientific-page">
<div class="calculator">
<div class="display rounded-xl bg-card border border-border p-4 mb-4">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-muted-foreground font-mono truncate flex-1"
>{expression || ' '}</span
>
<button
class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80"
onclick={() => (angleMode = angleMode === 'rad' ? 'deg' : 'rad')}
>
{angleMode.toUpperCase()}
</button>
</div>
<div
class="text-3xl font-bold text-foreground font-mono text-right tabular-nums truncate"
class:text-red-400={!!error}
>
{error || display}
</div>
</div>
<!-- Constants quick insert -->
<div class="flex gap-1 mb-3 overflow-x-auto pb-1">
{#each SCIENTIFIC_CONSTANTS.slice(0, 6) as constant}
<button
class="shrink-0 text-xs px-2 py-1 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
onclick={() => append(String(constant.value))}
title={constant.name}
>
{constant.symbol}
</button>
{/each}
</div>
<div class="grid grid-cols-4 gap-1.5">
{#each sciButtons as row}
{#each row as btn}
<button
class="calc-btn h-12 rounded-lg border border-border transition-all active:scale-95 {getButtonClass(
btn
)}"
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<div class="flex gap-2 mt-2">
<button
class="flex-1 h-9 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm"
onclick={clear}>C</button
>
<button
class="flex-1 h-9 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted text-sm"
onclick={backspace}>← DEL</button
>
</div>
</div>
<!-- History -->
<div class="history">
<h3 class="text-sm font-medium text-muted-foreground mb-3">Verlauf</h3>
{#if recentHistory.length === 0}
<p class="text-xs text-muted-foreground/60">Noch keine Berechnungen</p>
{:else}
<div class="space-y-2">
{#each recentHistory as calc}
<button
class="w-full text-left p-2 rounded-lg hover:bg-muted/50 transition-colors"
onclick={() => {
expression = calc.result;
display = calc.result;
hasResult = true;
}}
>
<div class="text-xs text-muted-foreground truncate font-mono">{calc.expression}</div>
<div class="text-sm font-medium text-foreground font-mono">= {calc.result}</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
<style>
.scientific-page {
max-width: 700px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 180px;
gap: 2rem;
align-items: start;
}
.calculator {
max-width: 380px;
}
@media (max-width: 640px) {
.scientific-page {
grid-template-columns: 1fr;
}
.calculator {
max-width: 100%;
}
}
</style>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { CALCULATOR_SKINS } from '@calc/shared/constants';
import type { CalculatorSkin } from '@calc/shared';
import { ModernSkin, HP35Skin, CasioSkin, TI84Skin, MinimalSkin } from '$lib/components/skins';
let previewSkin = $state<CalculatorSkin | null>(null);
// Demo props for preview
const demoProps = {
expression: '42 × 23',
display: '966',
error: '',
onButton: () => {},
onClear: () => {},
onBackspace: () => {},
onEquals: () => {},
};
function selectSkin(skin: CalculatorSkin) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calc-skin', skin);
}
previewSkin = skin;
}
const skinComponents: Record<CalculatorSkin, any> = {
modern: ModernSkin,
hp35: HP35Skin,
'casio-fx': CasioSkin,
ti84: TI84Skin,
minimal: MinimalSkin,
};
</script>
<svelte:head>
<title>Calc - Skins</title>
</svelte:head>
<div class="skins-page">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Rechner-Skins</h1>
<p class="text-muted-foreground text-sm mt-1">Wähle das Aussehen deines Taschenrechners</p>
</header>
<!-- Skin cards grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each CALCULATOR_SKINS as skin}
<div
class="skin-card rounded-2xl border border-border overflow-hidden transition-all hover:border-pink-500/50 hover:shadow-lg cursor-pointer"
class:ring-2={previewSkin === skin.id}
class:ring-pink-500={previewSkin === skin.id}
onclick={() => selectSkin(skin.id)}
role="button"
tabindex="0"
>
<!-- Preview -->
<div
class="skin-preview p-4 bg-card overflow-hidden"
style="max-height: 320px; pointer-events: none;"
>
<div class="scale-[0.65] origin-top-left" style="width: 153%; height: 153%;">
{#if skin.id === 'modern'}
<ModernSkin {...demoProps} />
{:else if skin.id === 'hp35'}
<HP35Skin {...demoProps} />
{:else if skin.id === 'casio-fx'}
<CasioSkin {...demoProps} />
{:else if skin.id === 'ti84'}
<TI84Skin {...demoProps} />
{:else if skin.id === 'minimal'}
<MinimalSkin {...demoProps} />
{/if}
</div>
</div>
<!-- Info -->
<div class="p-4 bg-background border-t border-border">
<div class="flex items-center justify-between">
<div>
<h3 class="font-bold text-foreground">{skin.label}</h3>
{#if skin.year}
<span class="text-xs text-pink-500 font-medium">{skin.year}</span>
{/if}
</div>
{#if previewSkin === skin.id}
<span class="text-xs px-2 py-1 rounded-full bg-pink-500 text-white">Aktiv</span>
{/if}
</div>
<p class="text-sm text-muted-foreground mt-1">{skin.description.de}</p>
</div>
</div>
{/each}
</div>
<!-- History section -->
<div class="mt-12">
<h2 class="text-lg font-bold text-foreground mb-4">Geschichte des Taschenrechners</h2>
<div class="space-y-4">
<div class="p-4 rounded-xl bg-card border border-border">
<div class="flex items-start gap-4">
<span class="text-2xl shrink-0">🏛️</span>
<div>
<h3 class="font-bold text-foreground">HP-35 (1972)</h3>
<p class="text-sm text-muted-foreground mt-1">
Der HP-35 war der weltweit erste wissenschaftliche Taschenrechner. Entwickelt von
Hewlett-Packard, machte er den Rechenschieber über Nacht obsolet. Sein Name kam daher,
dass er 35 Tasten hatte. Preis: $395 (heute ~$2.800).
</p>
</div>
</div>
</div>
<div class="p-4 rounded-xl bg-card border border-border">
<div class="flex items-start gap-4">
<span class="text-2xl shrink-0">🎒</span>
<div>
<h3 class="font-bold text-foreground">Casio fx-82 (1985)</h3>
<p class="text-sm text-muted-foreground mt-1">
Die Casio fx-Serie wurde zum Synonym für Schulrechner weltweit. Mit Solarzelle und dem
charakteristischen grün-grauen LCD-Display war er in fast jeder Schultasche zu finden.
Über 100 Millionen Stück verkauft.
</p>
</div>
</div>
</div>
<div class="p-4 rounded-xl bg-card border border-border">
<div class="flex items-start gap-4">
<span class="text-2xl shrink-0">📊</span>
<div>
<h3 class="font-bold text-foreground">TI-84 Plus (2004)</h3>
<p class="text-sm text-muted-foreground mt-1">
Der TI-84 Plus von Texas Instruments wurde zum Standard-Grafikrechner an
amerikanischen High Schools und Universitäten. Er konnte Funktionen plotten, Programme
ausführen und wurde trotz Smartphones nie abgelöst.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.skins-page {
max-width: 900px;
margin: 0 auto;
}
.skin-preview {
background-image: radial-gradient(circle at 50% 0%, hsl(var(--muted)) 0%, transparent 70%);
}
</style>

View file

@ -0,0 +1,252 @@
<script lang="ts">
import { getContext } from 'svelte';
import { evaluate, formatResult } from '$lib/engine/evaluate';
import { calculationsStore } from '$lib/stores/calculations.svelte';
import { CALCULATOR_SKINS } from '@calc/shared/constants';
import type { CalculatorSkin, Calculation } from '@calc/shared';
import { ModernSkin, HP35Skin, CasioSkin, TI84Skin, MinimalSkin } from '$lib/components/skins';
const allCalculations = getContext<{ value: Calculation[] }>('calculations');
let expression = $state('');
let display = $state('0');
let hasResult = $state(false);
let error = $state('');
// Skin state — persisted to localStorage
let activeSkin = $state<CalculatorSkin>('modern');
let showSkinPicker = $state(false);
// Load saved skin
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('calc-skin');
if (saved && CALCULATOR_SKINS.some((s) => s.id === saved)) {
activeSkin = saved as CalculatorSkin;
}
}
function setSkin(skin: CalculatorSkin) {
activeSkin = skin;
showSkinPicker = false;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calc-skin', skin);
}
}
function appendToExpression(char: string) {
if (hasResult) {
if (/[0-9.]/.test(char)) {
expression = '';
display = '';
hasResult = false;
} else {
expression = display;
hasResult = false;
}
}
error = '';
expression += char;
display = expression;
}
function clear() {
expression = '';
display = '0';
hasResult = false;
error = '';
}
function backspace() {
if (hasResult) {
clear();
return;
}
expression = expression.slice(0, -1);
display = expression || '0';
}
async function calculate() {
if (!expression.trim()) return;
try {
const result = evaluate(expression);
const formatted = formatResult(result);
await calculationsStore.addCalculation({
mode: 'standard',
expression: expression,
result: formatted,
skin: activeSkin,
});
display = formatted;
hasResult = true;
error = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (event.metaKey || event.ctrlKey) return;
if (/^[0-9.]$/.test(event.key)) {
event.preventDefault();
appendToExpression(event.key);
} else if (['+', '-', '*', '/', '%', '^'].includes(event.key)) {
event.preventDefault();
appendToExpression(event.key);
} else if (event.key === '(' || event.key === ')') {
event.preventDefault();
appendToExpression(event.key);
} else if (event.key === 'Enter' || event.key === '=') {
event.preventDefault();
calculate();
} else if (event.key === 'Backspace') {
event.preventDefault();
backspace();
} else if (event.key === 'Escape') {
event.preventDefault();
if (showSkinPicker) showSkinPicker = false;
else clear();
}
}
let recentHistory = $derived(
allCalculations.value.filter((c) => c.mode === 'standard').slice(0, 10)
);
// Shared props for all skins
let skinProps = $derived({
expression,
display,
error,
onButton: appendToExpression,
onClear: clear,
onBackspace: backspace,
onEquals: calculate,
});
</script>
<svelte:head>
<title>Calc - Standard</title>
</svelte:head>
<svelte:window onkeydown={handleKeydown} />
<div class="calculator-page">
<div class="calculator-column">
<!-- Skin picker toggle -->
<div class="flex items-center justify-between mb-3">
<button
class="skin-toggle text-xs px-3 py-1.5 rounded-full border transition-all
{showSkinPicker
? 'bg-pink-500 text-white border-pink-500'
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
onclick={() => (showSkinPicker = !showSkinPicker)}
>
🎨 {CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.label || 'Modern'}
{#if CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.year}
<span class="opacity-60">({CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.year})</span
>
{/if}
</button>
</div>
<!-- Skin picker panel -->
{#if showSkinPicker}
<div class="skin-picker mb-4 p-3 rounded-xl bg-card border border-border">
<div class="grid grid-cols-5 gap-2">
{#each CALCULATOR_SKINS as skin}
<button
class="skin-option p-2 rounded-lg text-center transition-all border
{activeSkin === skin.id ? 'border-pink-500 bg-pink-500/10' : 'border-transparent hover:bg-muted'}"
onclick={() => setSkin(skin.id)}
>
<div class="text-sm font-medium text-foreground">{skin.label}</div>
{#if skin.year}
<div class="text-xs text-muted-foreground">{skin.year}</div>
{/if}
</button>
{/each}
</div>
</div>
{/if}
<!-- Active Skin -->
{#if activeSkin === 'modern'}
<ModernSkin {...skinProps} />
{:else if activeSkin === 'hp35'}
<HP35Skin {...skinProps} />
{:else if activeSkin === 'casio-fx'}
<CasioSkin {...skinProps} />
{:else if activeSkin === 'ti84'}
<TI84Skin {...skinProps} />
{:else if activeSkin === 'minimal'}
<MinimalSkin {...skinProps} />
{/if}
</div>
<!-- History Sidebar -->
<div class="history">
<h3 class="text-sm font-medium text-muted-foreground mb-3">Verlauf</h3>
{#if recentHistory.length === 0}
<p class="text-xs text-muted-foreground/60">Noch keine Berechnungen</p>
{:else}
<div class="space-y-2">
{#each recentHistory as calc}
<button
class="w-full text-left p-2 rounded-lg hover:bg-muted/50 transition-colors group"
onclick={() => {
expression = calc.result;
display = calc.result;
hasResult = true;
}}
>
<div class="text-xs text-muted-foreground truncate font-mono">{calc.expression}</div>
<div class="text-sm font-medium text-foreground font-mono">= {calc.result}</div>
</button>
{/each}
</div>
<button
class="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
onclick={() => calculationsStore.clearHistory()}
>
Verlauf löschen
</button>
{/if}
</div>
</div>
<style>
.calculator-page {
max-width: 750px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 200px;
gap: 2rem;
align-items: start;
}
.calculator-column {
max-width: 400px;
}
@media (max-width: 640px) {
.calculator-page {
grid-template-columns: 1fr;
}
.calculator-column {
max-width: 100%;
}
.history {
order: -1;
max-height: 120px;
overflow-y: auto;
}
}
</style>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Calc - forgot-password</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Auth: forgot-password</p>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Calc - login</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Auth: login</p>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Calc - register</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Auth: register</p>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Calc - reset-password</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<p class="text-muted-foreground">Auth: reset-password</p>
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { isLoading as isLocaleLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props();
let loading = $state(true);
onMount(() => {
const cleanupErrorHandler = setupGlobalErrorHandler();
const init = async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
<ToastContainer />
{#if $isLocaleLoading || loading}
<AppLoadingSkeleton />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,2 @@
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
export const ssr = false;

View file

@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({ status: 'ok', app: 'calc-web' });
};

View file

@ -0,0 +1,13 @@
<svelte:head>
<title>Calc - Offline</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<div class="text-6xl mb-4">🧮</div>
<h1 class="text-2xl font-bold text-foreground mb-2">Offline</h1>
<p class="text-muted-foreground">
Calc funktioniert auch offline. Deine Daten sind lokal gespeichert.
</p>
</div>
</div>

View file

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
prerender: {
handleHttpError: ({ path, message }) => {
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,33 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [
sveltekit(),
SvelteKitPWA(
createPWAConfig({
name: 'Calc - Taschenrechner',
shortName: 'Calc',
description: 'Taschenrechner, Einheiten & Finanzen',
themeColor: '#ec4899',
preset: 'minimal',
})
),
],
server: {
port: 5198,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES],
},
define: {
...getBuildDefines(),
},
});

View file

@ -0,0 +1,19 @@
{
"name": "@calc/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./constants": "./src/constants/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,339 @@
import type { CalculatorMode, CalculatorSkin, UnitCategory, UnitDefinition } from '../types';
// ─── Calculator Mode Definitions ─────────────────────────
export const CALCULATOR_MODES: {
id: CalculatorMode;
label: { de: string; en: string };
icon: string;
}[] = [
{ id: 'standard', label: { de: 'Standard', en: 'Standard' }, icon: 'calculator' },
{ id: 'scientific', label: { de: 'Wissenschaftlich', en: 'Scientific' }, icon: 'flask' },
{ id: 'programmer', label: { de: 'Programmierer', en: 'Programmer' }, icon: 'code' },
{ id: 'converter', label: { de: 'Einheiten', en: 'Units' }, icon: 'ruler' },
{ id: 'currency', label: { de: 'Währung', en: 'Currency' }, icon: 'coins' },
{ id: 'finance', label: { de: 'Finanzen', en: 'Finance' }, icon: 'piggy-bank' },
{ id: 'date', label: { de: 'Datum', en: 'Date' }, icon: 'calendar' },
{ id: 'percentage', label: { de: 'Prozent & Trinkgeld', en: 'Percent & Tip' }, icon: 'percent' },
];
// ─── Calculator Skins ────────────────────────────────────
export const CALCULATOR_SKINS: {
id: CalculatorSkin;
label: string;
year?: number;
description: { de: string; en: string };
}[] = [
{
id: 'modern',
label: 'Modern',
description: { de: 'Minimalistisches Design', en: 'Minimalist design' },
},
{
id: 'hp35',
label: 'HP-35',
year: 1972,
description: {
de: 'Der erste wissenschaftliche Taschenrechner',
en: 'The first scientific pocket calculator',
},
},
{
id: 'casio-fx',
label: 'Casio fx',
year: 1985,
description: { de: 'Klassischer Schulrechner', en: 'Classic school calculator' },
},
{
id: 'ti84',
label: 'TI-84',
year: 2004,
description: { de: 'Grafischer Taschenrechner', en: 'Graphing calculator' },
},
{
id: 'minimal',
label: 'Minimal',
description: { de: 'Nur das Nötigste', en: 'Just the essentials' },
},
];
// ─── Unit Definitions ────────────────────────────────────
export const UNIT_CATEGORIES: {
id: UnitCategory;
label: { de: string; en: string };
icon: string;
}[] = [
{ id: 'length', label: { de: 'Länge', en: 'Length' }, icon: 'ruler' },
{ id: 'weight', label: { de: 'Gewicht', en: 'Weight' }, icon: 'scale' },
{ id: 'temperature', label: { de: 'Temperatur', en: 'Temperature' }, icon: 'thermometer' },
{ id: 'volume', label: { de: 'Volumen', en: 'Volume' }, icon: 'beaker' },
{ id: 'area', label: { de: 'Fläche', en: 'Area' }, icon: 'square' },
{ id: 'speed', label: { de: 'Geschwindigkeit', en: 'Speed' }, icon: 'gauge' },
{ id: 'time', label: { de: 'Zeit', en: 'Time' }, icon: 'clock' },
{ id: 'data', label: { de: 'Daten', en: 'Data' }, icon: 'database' },
{ id: 'energy', label: { de: 'Energie', en: 'Energy' }, icon: 'zap' },
{ id: 'pressure', label: { de: 'Druck', en: 'Pressure' }, icon: 'gauge' },
];
// Length units (base: meter)
export const LENGTH_UNITS: UnitDefinition[] = [
{
id: 'mm',
name: { de: 'Millimeter', en: 'Millimeter' },
symbol: 'mm',
toBase: (v) => v / 1000,
fromBase: (v) => v * 1000,
},
{
id: 'cm',
name: { de: 'Zentimeter', en: 'Centimeter' },
symbol: 'cm',
toBase: (v) => v / 100,
fromBase: (v) => v * 100,
},
{
id: 'm',
name: { de: 'Meter', en: 'Meter' },
symbol: 'm',
toBase: (v) => v,
fromBase: (v) => v,
},
{
id: 'km',
name: { de: 'Kilometer', en: 'Kilometer' },
symbol: 'km',
toBase: (v) => v * 1000,
fromBase: (v) => v / 1000,
},
{
id: 'in',
name: { de: 'Zoll', en: 'Inch' },
symbol: 'in',
toBase: (v) => v * 0.0254,
fromBase: (v) => v / 0.0254,
},
{
id: 'ft',
name: { de: 'Fuß', en: 'Foot' },
symbol: 'ft',
toBase: (v) => v * 0.3048,
fromBase: (v) => v / 0.3048,
},
{
id: 'yd',
name: { de: 'Yard', en: 'Yard' },
symbol: 'yd',
toBase: (v) => v * 0.9144,
fromBase: (v) => v / 0.9144,
},
{
id: 'mi',
name: { de: 'Meile', en: 'Mile' },
symbol: 'mi',
toBase: (v) => v * 1609.344,
fromBase: (v) => v / 1609.344,
},
{
id: 'nmi',
name: { de: 'Seemeile', en: 'Nautical Mile' },
symbol: 'nmi',
toBase: (v) => v * 1852,
fromBase: (v) => v / 1852,
},
];
// Weight units (base: kilogram)
export const WEIGHT_UNITS: UnitDefinition[] = [
{
id: 'mg',
name: { de: 'Milligramm', en: 'Milligram' },
symbol: 'mg',
toBase: (v) => v / 1_000_000,
fromBase: (v) => v * 1_000_000,
},
{
id: 'g',
name: { de: 'Gramm', en: 'Gram' },
symbol: 'g',
toBase: (v) => v / 1000,
fromBase: (v) => v * 1000,
},
{
id: 'kg',
name: { de: 'Kilogramm', en: 'Kilogram' },
symbol: 'kg',
toBase: (v) => v,
fromBase: (v) => v,
},
{
id: 't',
name: { de: 'Tonne', en: 'Metric Ton' },
symbol: 't',
toBase: (v) => v * 1000,
fromBase: (v) => v / 1000,
},
{
id: 'oz',
name: { de: 'Unze', en: 'Ounce' },
symbol: 'oz',
toBase: (v) => v * 0.0283495,
fromBase: (v) => v / 0.0283495,
},
{
id: 'lb',
name: { de: 'Pfund', en: 'Pound' },
symbol: 'lb',
toBase: (v) => v * 0.453592,
fromBase: (v) => v / 0.453592,
},
{
id: 'st',
name: { de: 'Stone', en: 'Stone' },
symbol: 'st',
toBase: (v) => v * 6.35029,
fromBase: (v) => v / 6.35029,
},
];
// Temperature units (special handling - not simple multiplication)
export const TEMPERATURE_UNITS: UnitDefinition[] = [
{
id: 'c',
name: { de: 'Celsius', en: 'Celsius' },
symbol: '°C',
toBase: (v) => v,
fromBase: (v) => v,
},
{
id: 'f',
name: { de: 'Fahrenheit', en: 'Fahrenheit' },
symbol: '°F',
toBase: (v) => ((v - 32) * 5) / 9,
fromBase: (v) => (v * 9) / 5 + 32,
},
{
id: 'k',
name: { de: 'Kelvin', en: 'Kelvin' },
symbol: 'K',
toBase: (v) => v - 273.15,
fromBase: (v) => v + 273.15,
},
];
// Volume units (base: liter)
export const VOLUME_UNITS: UnitDefinition[] = [
{
id: 'ml',
name: { de: 'Milliliter', en: 'Milliliter' },
symbol: 'ml',
toBase: (v) => v / 1000,
fromBase: (v) => v * 1000,
},
{
id: 'l',
name: { de: 'Liter', en: 'Liter' },
symbol: 'l',
toBase: (v) => v,
fromBase: (v) => v,
},
{
id: 'm3',
name: { de: 'Kubikmeter', en: 'Cubic Meter' },
symbol: 'm³',
toBase: (v) => v * 1000,
fromBase: (v) => v / 1000,
},
{
id: 'gal',
name: { de: 'Gallone (US)', en: 'Gallon (US)' },
symbol: 'gal',
toBase: (v) => v * 3.78541,
fromBase: (v) => v / 3.78541,
},
{
id: 'qt',
name: { de: 'Quart (US)', en: 'Quart (US)' },
symbol: 'qt',
toBase: (v) => v * 0.946353,
fromBase: (v) => v / 0.946353,
},
{
id: 'pt',
name: { de: 'Pint (US)', en: 'Pint (US)' },
symbol: 'pt',
toBase: (v) => v * 0.473176,
fromBase: (v) => v / 0.473176,
},
{
id: 'cup',
name: { de: 'Cup (US)', en: 'Cup (US)' },
symbol: 'cup',
toBase: (v) => v * 0.236588,
fromBase: (v) => v / 0.236588,
},
{
id: 'floz',
name: { de: 'Fluid Ounce (US)', en: 'Fluid Ounce (US)' },
symbol: 'fl oz',
toBase: (v) => v * 0.0295735,
fromBase: (v) => v / 0.0295735,
},
];
// Data units (base: byte)
export const DATA_UNITS: UnitDefinition[] = [
{ id: 'b', name: { de: 'Byte', en: 'Byte' }, symbol: 'B', toBase: (v) => v, fromBase: (v) => v },
{
id: 'kb',
name: { de: 'Kilobyte', en: 'Kilobyte' },
symbol: 'KB',
toBase: (v) => v * 1024,
fromBase: (v) => v / 1024,
},
{
id: 'mb',
name: { de: 'Megabyte', en: 'Megabyte' },
symbol: 'MB',
toBase: (v) => v * 1024 ** 2,
fromBase: (v) => v / 1024 ** 2,
},
{
id: 'gb',
name: { de: 'Gigabyte', en: 'Gigabyte' },
symbol: 'GB',
toBase: (v) => v * 1024 ** 3,
fromBase: (v) => v / 1024 ** 3,
},
{
id: 'tb',
name: { de: 'Terabyte', en: 'Terabyte' },
symbol: 'TB',
toBase: (v) => v * 1024 ** 4,
fromBase: (v) => v / 1024 ** 4,
},
];
// Map category to units
export const UNITS_BY_CATEGORY: Record<string, UnitDefinition[]> = {
length: LENGTH_UNITS,
weight: WEIGHT_UNITS,
temperature: TEMPERATURE_UNITS,
volume: VOLUME_UNITS,
data: DATA_UNITS,
};
// ─── Scientific Constants ────────────────────────────────
export const SCIENTIFIC_CONSTANTS = [
{ id: 'pi', name: 'Pi', symbol: 'π', value: Math.PI },
{ id: 'e', name: 'Euler', symbol: 'e', value: Math.E },
{ id: 'phi', name: 'Goldener Schnitt', symbol: 'φ', value: 1.6180339887 },
{ id: 'sqrt2', name: 'Wurzel 2', symbol: '√2', value: Math.SQRT2 },
{ id: 'c', name: 'Lichtgeschwindigkeit', symbol: 'c', value: 299792458 },
{ id: 'g', name: 'Erdbeschleunigung', symbol: 'g', value: 9.80665 },
{ id: 'avogadro', name: 'Avogadro', symbol: 'Nₐ', value: 6.02214076e23 },
{ id: 'planck', name: 'Planck', symbol: 'h', value: 6.62607015e-34 },
{ id: 'boltzmann', name: 'Boltzmann', symbol: 'k', value: 1.380649e-23 },
];

View file

@ -0,0 +1,2 @@
export * from './types';
export * from './constants';

View file

@ -0,0 +1,104 @@
// ─── Calculator Types ────────────────────────────────────
export type CalculatorMode =
| 'standard'
| 'scientific'
| 'programmer'
| 'converter'
| 'currency'
| 'finance'
| 'date'
| 'percentage';
export type CalculatorSkin = 'modern' | 'hp35' | 'casio-fx' | 'ti84' | 'minimal';
export interface Calculation {
id: string;
userId: string;
mode: CalculatorMode;
expression: string;
result: string;
skin?: CalculatorSkin;
createdAt: string;
}
export interface CreateCalculationInput {
mode: CalculatorMode;
expression: string;
result: string;
skin?: CalculatorSkin;
}
export interface SavedFormula {
id: string;
userId: string;
name: string;
expression: string;
description?: string;
mode: CalculatorMode;
createdAt: string;
updatedAt: string;
}
export interface CreateFormulaInput {
name: string;
expression: string;
description?: string;
mode: CalculatorMode;
}
export interface UpdateFormulaInput {
name?: string;
expression?: string;
description?: string;
}
// ─── Unit Converter ──────────────────────────────────────
export type UnitCategory =
| 'length'
| 'weight'
| 'temperature'
| 'volume'
| 'area'
| 'speed'
| 'time'
| 'data'
| 'energy'
| 'pressure';
export interface UnitDefinition {
id: string;
name: { de: string; en: string };
symbol: string;
toBase: (value: number) => number;
fromBase: (value: number) => number;
}
// ─── Programmer Calculator ───────────────────────────────
export type NumberBase = 'dec' | 'hex' | 'oct' | 'bin';
// ─── Finance Calculator ──────────────────────────────────
export type FinanceMode = 'compound-interest' | 'loan' | 'savings' | 'tip' | 'split';
export interface CompoundInterestInput {
principal: number;
rate: number; // annual rate in %
years: number;
compoundsPerYear: number; // 1=annual, 4=quarterly, 12=monthly
}
export interface LoanInput {
amount: number;
annualRate: number;
years: number;
}
export interface SavingsInput {
monthlyDeposit: number;
annualRate: number;
years: number;
initialDeposit?: number;
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -120,6 +120,10 @@
"taktik:dev": "turbo run dev --filter=taktik...",
"dev:taktik:web": "pnpm --filter @taktik/web dev",
"dev:taktik:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:taktik:web\"",
"calc:dev": "turbo run dev --filter=calc...",
"dev:calc:web": "pnpm --filter @calc/web dev",
"dev:calc:app": "pnpm dev:calc:web",
"dev:calc:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:calc:web\"",
"moodlit:dev": "turbo run dev --filter=moodlit...",
"dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev",
"dev:moodlit:web": "pnpm --filter @moodlit/web dev",

View file

@ -78,6 +78,9 @@ const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024"
// Taktik icon (clock with play button, amber gradient)
const taktikSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#taktikGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="taktikGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
// Calc icon (calculator with pink gradient)
const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
// Context icon (document/knowledge with sky blue gradient)
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
@ -110,6 +113,7 @@ export const APP_ICONS = {
context: svgToDataUrl(contextSvg),
citycorners: svgToDataUrl(citycornersSvg),
taktik: svgToDataUrl(taktikSvg),
calc: svgToDataUrl(calcSvg),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -372,6 +372,22 @@ export const MANA_APPS: ManaApp[] = [
comingSoon: false,
status: 'development',
},
{
id: 'calc',
name: 'Calc',
description: {
de: 'Taschenrechner & Umrechner',
en: 'Calculator & Converter',
},
longDescription: {
de: 'Taschenrechner mit Standard, Wissenschaftlich, Programmierer, Einheiten, Währung und Finanzrechnern.',
en: 'Calculator with standard, scientific, programmer, unit, currency and finance modes.',
},
icon: APP_ICONS.calc,
color: '#ec4899',
comingSoon: false,
status: 'development',
},
];
/**
@ -464,6 +480,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' },
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
taktik: { dev: 'http://localhost:5197', prod: 'https://taktik.mana.how' },
calc: { dev: 'http://localhost:5198', prod: 'https://calc.mana.how' },
};
/**

4575
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff