feat: dryer counter initial commot

This commit is contained in:
2025-12-06 12:38:49 +01:00
commit d9d9743d2c
35 changed files with 5282 additions and 0 deletions

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
services:
dryer-counter:
build: ./dryer-counter
ports:
- "3000:3000"
volumes:
- dryer-data:/app/data
environment:
- NODE_ENV=production
- PORT=3000
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
dryer-data:
driver: local

23
dryer-counter/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
dryer-counter/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

47
dryer-counter/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files and install production dependencies
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod --frozen-lockfile
# Copy built application
COPY --from=builder /app/build ./build
# Create data directory for SQLite
RUN mkdir -p /app/data
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "build"]

38
dryer-counter/README.md Normal file
View File

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

BIN
dryer-counter/data/dryer.db Normal file

Binary file not shown.

View File

@ -0,0 +1,32 @@
{
"name": "dryer-counter",
"private": true,
"version": "0.0.1",
"type": "module",
"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"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.10.1",
"mdsvex": "^0.12.6",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.2"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.4.0",
"better-sqlite3": "^12.5.0",
"chart.js": "^4.5.1",
"svelte-chartjs": "^3.1.5"
}
}

1551
dryer-counter/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- better-sqlite3
- esbuild

432
dryer-counter/src/app.css Normal file
View File

@ -0,0 +1,432 @@
/* Bold Pastel Papercut Theme for Dryer Counter App */
:root {
/* Bold Pastel Colors - Solid, vibrant */
--pastel-pink: #FFB5C5;
--pastel-pink-light: #FFCDD8;
--pastel-coral: #FFA07A;
--pastel-blue: #87CEEB;
--pastel-blue-light: #B0E0E6;
--pastel-green: #98D8AA;
--pastel-mint: #7DDBA3;
--pastel-yellow: #FFE66D;
--pastel-orange: #FFB347;
--pastel-lavender: #C9B1FF;
--pastel-purple: #DDA0DD;
/* Background Colors - Solid */
--bg-cream: #FFF5E6;
--bg-card: #FFFFFF;
--bg-mint: #E8F5E9;
/* Text Colors */
--text-dark: #2D2D2D;
--text-medium: #4A4A4A;
--text-light: #6B6B6B;
/* Papercut specific */
--border-dark: #2D2D2D;
--border-width: 3px;
--paper-shadow: 6px 6px 0px var(--border-dark);
--paper-shadow-sm: 4px 4px 0px var(--border-dark);
--paper-shadow-hover: 8px 8px 0px var(--border-dark);
/* Border Radius - Slightly rounded for paper feel */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
/* Transitions */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
}
/* Reset and Base Styles */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: 'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif;
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background: var(--bg-cream);
color: var(--text-dark);
min-height: 100vh;
/* Paper texture effect with dots */
background-image:
radial-gradient(circle, var(--pastel-lavender) 1px, transparent 1px),
radial-gradient(circle, var(--pastel-pink-light) 1px, transparent 1px);
background-size: 40px 40px, 60px 60px;
background-position: 0 0, 20px 20px;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.3;
color: var(--text-dark);
}
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
/* Card Component - Papercut style */
.card {
background: var(--bg-card);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-md);
box-shadow: var(--paper-shadow);
padding: var(--space-lg);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
position: relative;
}
.card:hover {
transform: translate(-2px, -2px);
box-shadow: var(--paper-shadow-hover);
}
/* Counter Card Styles - Bold solid colors */
.counter-card {
text-align: center;
padding: var(--space-xl);
position: relative;
overflow: hidden;
}
.counter-card.short {
background: var(--pastel-pink);
}
.counter-card.long {
background: var(--pastel-blue);
}
/* Papercut decoration effect */
.counter-card::before {
content: '';
position: absolute;
top: 10px;
left: 10px;
right: 10px;
bottom: 10px;
border: 2px dashed var(--border-dark);
border-radius: var(--radius-sm);
pointer-events: none;
opacity: 0.3;
}
.counter-number {
font-size: 5rem;
font-weight: 900;
line-height: 1;
margin: var(--space-md) 0;
color: var(--text-dark);
text-shadow: 3px 3px 0px rgba(0,0,0,0.1);
}
.counter-label {
font-size: 1.1rem;
color: var(--text-dark);
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 700;
}
/* Button Styles - Papercut */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--paper-shadow-sm);
text-transform: uppercase;
}
.btn:hover {
transform: translate(-2px, -2px);
box-shadow: var(--paper-shadow);
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0px var(--border-dark);
}
.btn-primary {
background: var(--pastel-yellow);
color: var(--text-dark);
}
.btn-secondary {
background: var(--pastel-mint);
color: var(--text-dark);
}
/* Grid Layout */
.grid {
display: grid;
gap: var(--space-lg);
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
.grid-2, .grid-3 {
grid-template-columns: 1fr;
}
}
/* Container */
.container {
max-width: 1000px;
margin: 0 auto;
padding: var(--space-lg);
}
/* Header - Papercut style */
.header {
background: var(--pastel-yellow);
border-bottom: var(--border-width) solid var(--border-dark);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-xl);
position: relative;
}
/* Zigzag border effect for header */
.header::after {
content: '';
position: absolute;
bottom: -15px;
left: 0;
right: 0;
height: 15px;
background:
linear-gradient(135deg, var(--pastel-yellow) 25%, transparent 25%) -12px 0,
linear-gradient(225deg, var(--pastel-yellow) 25%, transparent 25%) -12px 0,
linear-gradient(315deg, var(--pastel-yellow) 25%, transparent 25%),
linear-gradient(45deg, var(--pastel-yellow) 25%, transparent 25%);
background-size: 24px 15px;
background-color: transparent;
}
.header-content {
max-width: 1000px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.75rem;
font-weight: 900;
color: var(--text-dark);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Entry List - Papercut style */
.entry-list {
list-style: none;
}
.entry-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border-bottom: 2px dashed var(--border-dark);
transition: background var(--transition-fast);
}
.entry-item:last-child {
border-bottom: none;
}
.entry-item:hover {
background: var(--bg-cream);
}
.entry-icon {
width: 48px;
height: 48px;
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 3px 3px 0px var(--border-dark);
}
.entry-icon.short {
background: var(--pastel-pink);
}
.entry-icon.long {
background: var(--pastel-blue);
}
.entry-details {
flex: 1;
}
.entry-type {
font-weight: 700;
color: var(--text-dark);
font-size: 1.1rem;
}
.entry-time {
font-size: 0.9rem;
color: var(--text-medium);
font-weight: 500;
}
/* Chart Container */
.chart-container {
position: relative;
height: 300px;
width: 100%;
background: var(--bg-cream);
border: 2px dashed var(--border-dark);
border-radius: var(--radius-sm);
padding: var(--space-sm);
}
/* Status Indicator - Papercut style */
.status-dot {
width: 12px;
height: 12px;
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
display: inline-block;
margin-right: var(--space-xs);
}
.status-dot.connected {
background: var(--pastel-green);
}
.status-dot.disconnected {
background: var(--pastel-coral);
}
/* Loading State */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl);
}
.spinner {
width: 40px;
height: 40px;
border: var(--border-width) solid var(--border-dark);
border-top-color: var(--pastel-yellow);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--space-2xl);
color: var(--text-medium);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: var(--space-md);
}
/* Section Title - Papercut style */
.section-title {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-sm);
border-bottom: 3px solid var(--border-dark);
}
.section-title span {
font-size: 1.5rem;
}
.section-title h2 {
margin: 0;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Badge/Tag style */
.tag {
display: inline-block;
padding: var(--space-xs) var(--space-sm);
background: var(--pastel-lavender);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
}
/* Code blocks - Papercut style */
code {
font-family: 'Courier New', monospace;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-weight: 600;
}

13
dryer-counter/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<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 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,541 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
shortCount: number;
longCount: number;
startDate?: string;
endDate?: string;
}
let { shortCount, longCount, startDate, endDate }: Props = $props();
interface CostSettings {
eur_per_kwh: number;
kwh_short_cycle: number;
kwh_long_cycle: number;
eur_short_cycle?: number;
eur_long_cycle?: number;
}
interface CostCalculation {
shortCostPerCycle: number;
longCostPerCycle: number;
totalShortCost: number;
totalLongCost: number;
totalCost: number;
settings: CostSettings;
}
// Form state
let eur_per_kwh = $state(0.3);
let kwh_short_cycle = $state(1.0);
let kwh_long_cycle = $state(1.6);
let eur_short_cycle = $state<number | undefined>(undefined);
let eur_long_cycle = $state<number | undefined>(undefined);
// Calculated costs
let costs = $state<CostCalculation | null>(null);
// UI state
let isEditing = $state(false);
let isLoading = $state(false);
async function loadCostSettings() {
try {
const response = await fetch('/api/cost-settings');
if (response.ok) {
const settings = await response.json();
eur_per_kwh = settings.eur_per_kwh;
kwh_short_cycle = settings.kwh_short_cycle;
kwh_long_cycle = settings.kwh_long_cycle;
eur_short_cycle = settings.eur_short_cycle;
eur_long_cycle = settings.eur_long_cycle;
}
} catch (error) {
console.error('Error loading cost settings:', error);
}
}
async function saveCostSettings() {
isLoading = true;
try {
const settings = {
eur_per_kwh,
kwh_short_cycle,
kwh_long_cycle,
eur_short_cycle: eur_short_cycle || null,
eur_long_cycle: eur_long_cycle || null
};
const response = await fetch('/api/cost-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings)
});
if (response.ok) {
isEditing = false;
await calculateCosts();
} else {
throw new Error('Failed to save cost settings');
}
} catch (error) {
console.error('Error saving cost settings:', error);
} finally {
isLoading = false;
}
}
async function calculateCosts() {
try {
const url = new URL('/api/calculate-costs', window.location.origin);
url.searchParams.set('shortCount', shortCount.toString());
url.searchParams.set('longCount', longCount.toString());
if (startDate) url.searchParams.set('startDate', startDate);
if (endDate) url.searchParams.set('endDate', endDate);
const response = await fetch(url.toString());
if (response.ok) {
costs = await response.json();
}
} catch (error) {
console.error('Error calculating costs:', error);
}
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
// Format date range for display (matching RecentEntries format)
function getFormattedDateRange(startDate: string, endDate: string): string {
const start = new Date(startDate);
const end = new Date(endDate);
const formatDate = (date: Date) => {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short'
});
};
return `${formatDate(start)} - ${formatDate(end)}`;
}
function startEditing() {
isEditing = true;
}
function cancelEditing() {
isEditing = false;
loadCostSettings(); // Reset to saved values
}
onMount(() => {
loadCostSettings();
calculateCosts();
});
// Recalculate when counts change
$effect(() => {
calculateCosts();
});
</script>
<div class="card calculator-card">
<div class="calculator-header">
<div class="section-title">
<span>💰</span>
<h2>Kostenrechner</h2>
</div>
{#if !isEditing}
<button
class="btn-edit"
onclick={startEditing}
disabled={isLoading}
>
⚙️ Bearbeiten
</button>
{/if}
</div>
{#if isEditing}
<div class="settings-form">
<div class="form-grid">
<div class="form-group">
<label for="eur_per_kwh">€/kWh</label>
<input
id="eur_per_kwh"
type="number"
step="0.01"
min="0"
bind:value={eur_per_kwh}
class="form-input"
/>
</div>
<div class="form-group">
<label for="kwh_short_cycle">kWh Kurzzyklus</label>
<input
id="kwh_short_cycle"
type="number"
step="0.1"
min="0"
bind:value={kwh_short_cycle}
class="form-input"
/>
</div>
<div class="form-group">
<label for="kwh_long_cycle">kWh Langzyklus</label>
<input
id="kwh_long_cycle"
type="number"
step="0.1"
min="0"
bind:value={kwh_long_cycle}
class="form-input"
/>
</div>
<div class="form-group">
<label for="eur_short_cycle">€/Kurzzyklus (optional)</label>
<input
id="eur_short_cycle"
type="number"
step="0.01"
min="0"
bind:value={eur_short_cycle}
class="form-input"
placeholder="Automatisch berechnet"
/>
</div>
<div class="form-group">
<label for="eur_long_cycle">€/Langzyklus (optional)</label>
<input
id="eur_long_cycle"
type="number"
step="0.01"
min="0"
bind:value={eur_long_cycle}
class="form-input"
placeholder="Automatisch berechnet"
/>
</div>
</div>
<div class="form-actions">
<button
class="btn-cancel"
onclick={cancelEditing}
disabled={isLoading}
>
Abbrechen
</button>
<button
class="btn-save"
onclick={saveCostSettings}
disabled={isLoading}
>
{isLoading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{:else if costs}
<div class="cost-display">
<div class="cost-summary">
<div class="cost-item">
<span class="cost-label">Kosten Kurzzyklus:</span>
<span class="cost-value">{formatCurrency(costs.shortCostPerCycle)}</span>
</div>
<div class="cost-item">
<span class="cost-label">Kosten Langzyklus:</span>
<span class="cost-value">{formatCurrency(costs.longCostPerCycle)}</span>
</div>
<div class="cost-divider"></div>
<div class="cost-item total">
<span class="cost-label">Gesamtkosten:</span>
<span class="cost-value">{formatCurrency(costs.totalCost)}</span>
</div>
</div>
<div class="cost-breakdown">
<div class="breakdown-item">
<span class="breakdown-label">
{costs.shortCount || shortCount} Kurzzyklen:
</span>
<span class="breakdown-value">
{formatCurrency(costs.totalShortCost)}
</span>
</div>
<div class="breakdown-item">
<span class="breakdown-label">
{costs.longCount || longCount} Langzyklen:
</span>
<span class="breakdown-value">
{formatCurrency(costs.totalLongCost)}
</span>
</div>
</div>
{#if costs.dateRange}
<div class="date-range-info">
📅 Kosten für Zeitraum: {getFormattedDateRange(costs.dateRange.start, costs.dateRange.end)}
</div>
{:else}
<div class="date-range-info">
📅 Kosten für den gesamten Zeitraum
</div>
{/if}
{#if costs.settings.updated_at}
<div class="last-updated">
Zuletzt aktualisiert: {new Date(costs.settings.updated_at).toLocaleDateString('de-DE')}
</div>
{/if}
</div>
{/if}
</div>
<div class="paper-corner"></div>
<style>
.calculator-card {
position: relative;
}
.calculator-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.btn-edit {
background: var(--pastel-blue);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: 2px 2px 0px var(--border-dark);
}
.btn-edit:hover:not(:disabled) {
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0px var(--border-dark);
}
.btn-edit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Settings Form */
.settings-form {
margin-bottom: var(--space-lg);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-group label {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-medium);
}
.form-input {
padding: var(--space-sm);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
background: var(--bg-card);
font-size: 1rem;
transition: all var(--transition-fast);
}
.form-input:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.form-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.btn-cancel, .btn-save {
padding: var(--space-sm) var(--space-lg);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: 2px 2px 0px var(--border-dark);
}
.btn-cancel {
background: var(--pastel-coral);
}
.btn-save {
background: var(--pastel-mint);
}
.btn-cancel:hover:not(:disabled), .btn-save:hover:not(:disabled) {
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0px var(--border-dark);
}
.btn-cancel:disabled, .btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Cost Display */
.cost-display {
text-align: center;
}
.cost-summary {
background: var(--bg-section);
padding: var(--space-lg);
border-radius: var(--radius-sm);
margin-bottom: var(--space-lg);
border: 1px solid var(--border-light);
}
.cost-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-sm);
font-size: 1.1rem;
}
.cost-item:last-child {
margin-bottom: 0;
}
.cost-item.total {
font-weight: 700;
font-size: 1.3rem;
color: var(--accent-green);
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 2px solid var(--border-medium);
}
.cost-label {
color: var(--text-medium);
}
.cost-value {
font-weight: 600;
font-family: monospace;
}
.cost-divider {
height: 1px;
background: var(--border-light);
margin: var(--space-md) 0;
}
.cost-breakdown {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.breakdown-item {
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: var(--text-medium);
}
.breakdown-label {
font-weight: 500;
}
.breakdown-value {
font-family: monospace;
font-weight: 600;
}
.date-range-info {
margin-top: var(--space-md);
display: inline-block;
font-size: 0.8rem;
color: var(--text-medium);
font-weight: 500;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 2px 2px 0px var(--border-dark);
}
.last-updated {
margin-top: var(--space-md);
font-size: 0.8rem;
color: var(--text-light);
font-style: italic;
}
/* Responsive */
@media (max-width: 768px) {
.calculator-header {
flex-direction: column;
gap: var(--space-sm);
align-items: stretch;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-actions {
justify-content: stretch;
}
.btn-cancel, .btn-save {
flex: 1;
}
.cost-item {
flex-direction: column;
gap: var(--space-xs);
text-align: left;
}
.breakdown-item {
flex-direction: column;
gap: var(--space-xs);
text-align: left;
}
}
</style>

View File

@ -0,0 +1,145 @@
<script lang="ts">
interface Props {
type: 'short' | 'long';
count: number;
onIncrement: () => void;
onDecrement: () => void;
}
let { type, count, onIncrement, onDecrement }: Props = $props();
const icons = {
short: '⚡',
long: '🌀'
};
const labels = {
short: 'Short Cycles',
long: 'Long Cycles'
};
</script>
<div class="card counter-card {type}">
<div class="counter-badge">{type === 'short' ? 'QUICK' : 'FULL'}</div>
<div class="counter-icon">{icons[type]}</div>
<div class="counter-number">{count}</div>
<div class="counter-label">{labels[type]}</div>
<div class="counter-controls">
<button
class="btn-counter btn-decrement"
onclick={onDecrement}
aria-label="Decrement {type} cycles"
>
</button>
<button
class="btn-counter btn-increment"
onclick={onIncrement}
aria-label="Increment {type} cycles"
>
+
</button>
</div>
<div class="paper-corner"></div>
</div>
<style>
.counter-card {
position: relative;
}
.counter-badge {
position: absolute;
top: -8px;
right: 20px;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
padding: 2px 12px;
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 1px;
transform: rotate(3deg);
box-shadow: 2px 2px 0px var(--border-dark);
}
.counter-icon {
font-size: 3.5rem;
margin-bottom: var(--space-sm);
filter: drop-shadow(3px 3px 0px rgba(0,0,0,0.15));
}
.paper-corner {
position: absolute;
bottom: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 30px 30px;
border-color: transparent transparent var(--bg-cream) transparent;
}
.paper-corner::before {
content: '';
position: absolute;
bottom: -30px;
right: 0;
width: 30px;
height: 30px;
background: linear-gradient(135deg, rgba(0,0,0,0.1) 0%, transparent 50%);
}
/* Counter Controls */
.counter-controls {
display: flex;
gap: var(--space-sm);
justify-content: center;
margin-top: var(--space-md);
}
.btn-counter {
width: 48px;
height: 48px;
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
background: var(--bg-card);
font-size: 1.75rem;
font-weight: 900;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: 3px 3px 0px var(--border-dark);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dark);
}
.btn-counter:hover {
transform: translate(-2px, -2px);
box-shadow: var(--paper-shadow);
}
.btn-counter:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0px var(--border-dark);
}
.btn-increment {
background: var(--pastel-mint);
}
.btn-decrement {
background: var(--pastel-coral);
}
/* Type-specific decorations */
:global(.counter-card.short) .counter-badge {
background: var(--pastel-coral);
}
:global(.counter-card.long) .counter-badge {
background: var(--pastel-lavender);
}
</style>

View File

@ -0,0 +1,418 @@
<script lang="ts">
interface Props {
startDate?: string;
endDate?: string;
onDateRangeChange: (startDate: string, endDate: string) => void;
}
let { startDate = '', endDate = '', onDateRangeChange }: Props = $props();
// Reactive state for the selector
let selectedStartDate = $state(startDate);
let selectedEndDate = $state(endDate);
let minDate = $state('');
let maxDate = $state('');
// Available quick selection periods
const quickPeriods = [
{ label: '1 Monat', months: 1 },
{ label: '3 Monate', months: 3 },
{ label: '6 Monate', months: 6 },
{ label: '1 Jahr', months: 12 }
];
// Initialize date range
$effect(() => {
const today = new Date();
maxDate = formatDateForInput(today);
// Default to 1 year ago if no dates provided
if (!startDate || !endDate) {
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
if (!startDate) {
selectedStartDate = formatDateForInput(oneYearAgo);
minDate = formatDateForInput(oneYearAgo);
}
if (!endDate) {
selectedEndDate = formatDateForInput(today);
}
// Trigger initial callback
onDateRangeChange(selectedStartDate, selectedEndDate);
} else {
selectedStartDate = startDate;
selectedEndDate = endDate;
// Set reasonable min date (2 years before start date)
const minDateObj = new Date(startDate);
minDateObj.setFullYear(minDateObj.getFullYear() - 2);
minDate = formatDateForInput(minDateObj);
}
});
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function handleStartDateChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedStartDate = target.value;
updateDateRange();
}
function handleEndDateChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedEndDate = target.value;
updateDateRange();
}
function handleQuickPeriod(months: number) {
const today = new Date();
const pastDate = new Date(today);
pastDate.setMonth(pastDate.getMonth() - months);
selectedStartDate = formatDateForInput(pastDate);
selectedEndDate = formatDateForInput(today);
onDateRangeChange(selectedStartDate, selectedEndDate);
}
function handleReset() {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
selectedStartDate = formatDateForInput(oneYearAgo);
selectedEndDate = formatDateForInput(today);
onDateRangeChange(selectedStartDate, selectedEndDate);
}
function updateDateRange() {
if (selectedStartDate && selectedEndDate) {
onDateRangeChange(selectedStartDate, selectedEndDate);
}
}
function getDisplayDate(dateString: string): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
// Calculate date difference for display
$effect(() => {
// Auto-adjust end date if it's before start date
if (selectedStartDate && selectedEndDate && new Date(selectedEndDate) < new Date(selectedStartDate)) {
selectedEndDate = selectedStartDate;
}
});
</script>
<div class="date-range-selector">
<div class="date-range-header">
<div class="date-range-title">
<span class="date-range-icon">📅</span>
<h3>Zeitraumauswahl</h3>
</div>
<button
class="btn-reset btn"
onclick={handleReset}
aria-label="Zeitraum zurücksetzen"
>
↺ Zurücksetzen
</button>
</div>
<div class="date-range-content">
<!-- Date inputs -->
<div class="date-inputs">
<div class="date-input-group">
<label for="start-date">Von:</label>
<input
id="start-date"
type="date"
bind:value={selectedStartDate}
min={minDate}
max={maxDate}
oninput={handleStartDateChange}
class="date-input"
/>
</div>
<div class="date-separator"></div>
<div class="date-input-group">
<label for="end-date">Bis:</label>
<input
id="end-date"
type="date"
bind:value={selectedEndDate}
min={minDate}
max={maxDate}
oninput={handleEndDateChange}
class="date-input"
/>
</div>
</div>
<!-- Quick selection buttons -->
<div class="quick-periods">
<div class="quick-periods-label">Schnellauswahl:</div>
<div class="quick-periods-grid">
{#each quickPeriods as period}
<button
class="btn-quick-period btn"
onclick={() => handleQuickPeriod(period.months)}
aria-label={`Zeitraum ${period.label}`}
>
{period.label}
</button>
{/each}
</div>
</div>
<!-- Current range display -->
<div class="current-range-display">
<span class="current-range-label">Aktueller Zeitraum:</span>
<span class="current-range-dates">
{getDisplayDate(selectedStartDate)} bis {getDisplayDate(selectedEndDate)}
</span>
</div>
</div>
</div>
<style>
.date-range-selector {
background: var(--pastel-lavender);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-lg);
box-shadow: var(--paper-shadow);
padding: var(--space-lg);
margin-bottom: var(--space-xl);
position: relative;
}
.date-range-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 2px dashed var(--border-dark);
}
.date-range-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.date-range-icon {
font-size: 1.5rem;
}
.date-range-title h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-dark);
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-reset {
background: var(--pastel-coral);
color: var(--text-dark);
font-size: 0.9rem;
padding: var(--space-xs) var(--space-md);
min-height: auto;
}
.btn-reset:hover {
background: var(--pastel-orange);
}
.date-range-content {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.date-inputs {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
.date-input-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
flex: 1;
min-width: 140px;
}
.date-input-group label {
font-weight: 700;
color: var(--text-dark);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.date-input {
padding: var(--space-sm);
border: var(--border-width) solid var(--border-dark);
border-radius: var(--radius-sm);
background: var(--bg-card);
font-family: inherit;
font-size: 1rem;
font-weight: 600;
color: var(--text-dark);
box-shadow: 2px 2px 0px var(--border-dark);
transition: all var(--transition-fast);
}
.date-input:focus {
outline: none;
border-color: var(--pastel-yellow);
box-shadow: 0 0 0 3px rgba(255, 182, 193, 0.3), 2px 2px 0px var(--border-dark);
background: var(--bg-cream);
}
.date-separator {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
margin-top: var(--space-lg);
text-shadow: 1px 1px 0px rgba(0,0,0,0.1);
}
.quick-periods {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.quick-periods-label {
font-weight: 700;
color: var(--text-dark);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quick-periods-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-sm);
}
.btn-quick-period {
background: var(--pastel-mint);
color: var(--text-dark);
font-size: 0.9rem;
padding: var(--space-sm);
min-height: auto;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all var(--transition-fast);
}
.btn-quick-period:hover {
background: var(--pastel-green);
transform: translate(-1px, -1px);
box-shadow: var(--paper-shadow);
}
.btn-quick-period:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0px var(--border-dark);
}
.current-range-display {
background: var(--bg-cream);
border: 2px dashed var(--border-dark);
border-radius: var(--radius-sm);
padding: var(--space-md);
text-align: center;
margin-top: var(--space-sm);
}
.current-range-label {
display: block;
font-size: 0.85rem;
color: var(--text-medium);
margin-bottom: var(--space-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.current-range-dates {
font-size: 1rem;
font-weight: 700;
color: var(--text-dark);
letter-spacing: 0.5px;
}
/* Decorative paper corner */
.date-range-selector::after {
content: '';
position: absolute;
top: -8px;
right: 20px;
width: 30px;
height: 30px;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
transform: rotate(45deg);
box-shadow: 2px 2px 0px var(--border-dark);
z-index: 1;
}
/* Responsive design */
@media (max-width: 768px) {
.date-range-header {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.date-inputs {
flex-direction: column;
align-items: stretch;
}
.date-separator {
text-align: center;
margin-top: 0;
}
.quick-periods-grid {
grid-template-columns: repeat(2, 1fr);
}
.date-range-selector::after {
right: 10px;
}
}
@media (max-width: 480px) {
.quick-periods-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,313 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend
} from 'chart.js';
interface DailyStat {
date: string;
type: 'short' | 'long';
count: number;
}
interface Props {
stats: DailyStat[];
title?: string;
showDateRange?: boolean;
dateRange?: { start: string; end: string };
}
let { stats, title = 'Daily Dry Cycles', showDateRange = false, dateRange }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
// Register Chart.js components
if (browser) {
Chart.register(
BarController,
BarElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend
);
}
function processStats(rawStats: DailyStat[]) {
// Group by date
const dateMap = new Map<string, { short: number; long: number }>();
rawStats.forEach((stat) => {
const existing = dateMap.get(stat.date) || { short: 0, long: 0 };
existing[stat.type] = stat.count;
dateMap.set(stat.date, existing);
});
// Sort by date and get last 14 days
const sortedDates = Array.from(dateMap.keys()).sort().slice(-14);
return {
labels: sortedDates.map((date) => {
const d = new Date(date);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}),
shortData: sortedDates.map((date) => dateMap.get(date)?.short || 0),
longData: sortedDates.map((date) => dateMap.get(date)?.long || 0)
};
}
function createChart() {
if (!browser || !canvas) return;
const { labels, shortData, longData } = processStats(stats);
if (chart) {
chart.destroy();
}
chart = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Short Cycles',
data: shortData,
backgroundColor: '#FFB5C5',
borderColor: '#2D2D2D',
borderWidth: 3,
borderRadius: 4
},
{
label: 'Long Cycles',
data: longData,
backgroundColor: '#87CEEB',
borderColor: '#2D2D2D',
borderWidth: 3,
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: false,
padding: 20,
font: {
family: "'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif",
size: 14,
weight: 'bold'
},
// Custom legend boxes
generateLabels: function(chart) {
return [
{
text: 'Short Cycles',
fillStyle: '#FFB5C5',
strokeStyle: '#2D2D2D',
lineWidth: 3,
pointStyle: 'rectRounded'
},
{
text: 'Long Cycles',
fillStyle: '#87CEEB',
strokeStyle: '#2D2D2D',
lineWidth: 3,
pointStyle: 'rectRounded'
}
];
}
}
},
tooltip: {
backgroundColor: '#FFFFFF',
titleColor: '#2D2D2D',
bodyColor: '#4A4A4A',
borderColor: '#2D2D2D',
borderWidth: 3,
cornerRadius: 0,
padding: 12,
titleFont: {
family: "'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif",
weight: 'bold',
size: 14
},
bodyFont: {
family: "'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif",
size: 12
},
displayColors: true,
boxPadding: 6
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
family: "'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif",
weight: 'bold',
size: 12
},
color: '#2D2D2D'
}
},
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
font: {
family: "'Comic Sans MS', 'Chalkboard', 'Marker Felt', sans-serif",
weight: 'bold',
size: 12
},
color: '#2D2D2D'
},
grid: {
color: '#2D2D2D',
drawBorder: true,
borderDash: [5, 5],
lineWidth: 2
}
}
},
// Add spacing between bars
barPercentage: 0.8,
categoryPercentage: 0.9
}
});
}
onMount(() => {
createChart();
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
// Format date range for display (matching RecentEntries format)
function getFormattedDateRange(startDate: string, endDate: string): string {
const start = new Date(startDate);
const end = new Date(endDate);
const formatDate = (date: Date) => {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short'
});
};
return `${formatDate(start)} - ${formatDate(end)}`;
}
// Recreate chart when stats change
$effect(() => {
if (stats && browser) {
createChart();
}
});
</script>
<div class="card chart-card">
<div class="section-title">
<span>📊</span>
<h2>
{title}
{#if showDateRange && dateRange}
<span class="date-range-indicator">
({getFormattedDateRange(dateRange.start, dateRange.end)})
</span>
{/if}
</h2>
</div>
<div class="chart-wrapper">
<div class="chart-container">
<canvas bind:this={canvas}></canvas>
</div>
</div>
<div class="chart-tape"></div>
</div>
<style>
.chart-card {
position: relative;
background: var(--bg-cream);
}
.chart-wrapper {
position: relative;
padding: var(--space-md);
}
.chart-container {
position: relative;
height: 300px;
width: 100%;
background: #FFFFFF;
border: 3px solid var(--border-dark);
border-radius: var(--radius-sm);
}
.chart-tape {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%) rotate(-2deg);
width: 120px;
height: 25px;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
box-shadow: 2px 2px 0px var(--border-dark);
}
.chart-tape::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 5px,
rgba(0,0,0,0.05) 5px,
rgba(0,0,0,0.05) 10px
);
}
/* Date range indicator */
.date-range-indicator {
display: inline-block;
font-size: 0.8rem;
color: var(--text-medium);
font-weight: 500;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
padding: 2px 8px;
margin-left: var(--space-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 2px 2px 0px var(--border-dark);
}
</style>

View File

@ -0,0 +1,506 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
interface Entry {
id: number;
type: 'short' | 'long';
action: 'increment' | 'decrement';
created_at: string;
}
interface Props {
entries: Entry[];
showDateRange?: boolean;
dateRange?: { start: string; end: string };
refreshKey?: number;
}
let { entries: initialEntries, showDateRange = false, dateRange, refreshKey = 0 }: Props = $props();
// Reactive state for lazy loading
let entries = $state(initialEntries);
let loading = $state(false);
let hasMore = $state(true);
let offset = $state(0);
let loadingElement: HTMLElement;
let observer: IntersectionObserver;
// Reset internal state when props change or refreshKey increments
$effect(() => {
entries = initialEntries;
offset = 0;
hasMore = true;
loading = false;
// Reset intersection observer
if (observer) {
observer.disconnect();
}
// Setup new observer after a brief delay to ensure DOM is ready
setTimeout(() => {
if (loadingElement) {
setupIntersectionObserver();
}
}, 100);
});
const typeIcons = {
short: '⚡',
long: '🌀'
};
const actionIcons = {
increment: '+',
decrement: ''
};
function getEntryLabel(entry: Entry): string {
const typeLabel = entry.type === 'short' ? 'Short cycle' : 'Long cycle';
const actionLabel = entry.action === 'increment' ? 'added' : 'removed';
return `${typeLabel} ${actionLabel}`;
}
function getEntryIcon(entry: Entry): string {
const typeIcon = entry.type === 'short' ? '⚡' : '🌀';
const actionIcon = entry.action === 'increment' ? '+' : '';
return `${typeIcon} ${actionIcon}`;
}
function getEntryClass(entry: Entry): string {
return `${entry.type} ${entry.action}`;
}
function formatTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Calculate relative time
let relativeTime;
if (diffMins < 1) {
relativeTime = 'Gerade eben';
} else if (diffMins < 60) {
relativeTime = `vor ${diffMins} Minute${diffMins > 1 ? 'n' : ''}`;
} else if (diffHours < 24) {
relativeTime = `vor ${diffHours} Stunde${diffHours > 1 ? 'n' : ''}`;
} else {
relativeTime = `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`;
}
// German date formatting
const dateStr = date.toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
const timeStr = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
// Always show both relative time and exact date
return `${relativeTime}, am ${dateStr} um ${timeStr} Uhr`;
}
// Format date range for display
function getFormattedDateRange(): string {
if (!dateRange?.start || !dateRange?.end) return '';
const start = new Date(dateRange.start);
const end = new Date(dateRange.end);
const formatDate = (date: Date) => {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short'
});
};
return `${formatDate(start)} - ${formatDate(end)}`;
}
async function loadMoreEntries() {
if (loading || !hasMore) return;
loading = true;
try {
const newOffset = offset + 20;
const url = new URL('/api/dry/entries', window.location.origin);
url.searchParams.set('limit', '20');
url.searchParams.set('offset', newOffset.toString());
if (dateRange?.start) {
url.searchParams.set('startDate', dateRange.start);
}
if (dateRange?.end) {
url.searchParams.set('endDate', dateRange.end);
}
const response = await fetch(url.toString());
if (!response.ok) throw new Error('Failed to load more entries');
const data = await response.json();
entries = [...entries, ...data.entries];
hasMore = data.hasMore;
offset = newOffset;
} catch (error) {
console.error('Error loading more entries:', error);
} finally {
loading = false;
}
}
function setupIntersectionObserver() {
if (!browser || !loadingElement) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMoreEntries();
}
},
{
root: null,
rootMargin: '100px',
threshold: 0.1
}
);
observer.observe(loadingElement);
}
onMount(() => {
setupIntersectionObserver();
return () => {
if (observer) {
observer.disconnect();
}
};
});
</script>
<div class="card entries-card">
<div class="section-title">
<span>📋</span>
<h2>
Letzte Einträge
{#if showDateRange && dateRange}
<span class="date-range-indicator">
({getFormattedDateRange()})
</span>
{/if}
</h2>
</div>
{#if entries.length === 0}
<div class="empty-state">
<div class="empty-state-icon">🧺</div>
{#if showDateRange && dateRange}
<p>Keine Einträge im gewählten Zeitraum gefunden</p>
{:else}
<p>Noch keine Trockengänge aufgezeichnet</p>
{/if}
<div class="empty-doodle"></div>
</div>
{:else}
<div class="entries-wrapper">
<ul class="entry-list">
{#each entries as entry, index (entry.id)}
<li class="entry-item" style="--delay: {index < 20 ? index * 50 : 0}ms">
<div class="entry-icon {getEntryClass(entry)}">
{typeIcons[entry.type]}
</div>
<div class="entry-details">
<div class="entry-type">
{getEntryLabel(entry)}
<span class="entry-badge {entry.action}">
{actionIcons[entry.action]}
</span>
</div>
<div class="entry-time">{formatTime(entry.created_at)}</div>
</div>
<div class="entry-tick" class:decrement={entry.action === 'decrement'}>
{entry.action === 'increment' ? '✓' : '✗'}
</div>
</li>
{/each}
</ul>
{#if hasMore}
<div bind:this={loadingElement} class="loading-trigger">
{#if loading}
<div class="loading-spinner">🔄 Lade mehr Einträge...</div>
{:else}
<div class="load-more-hint">⬇️ Scrollen für mehr Einträge</div>
{/if}
</div>
{:else if entries.length > 20}
<div class="all-loaded">
<div class="all-loaded-icon"></div>
<p>Alle Einträge geladen ({entries.length} insgesamt)</p>
</div>
{/if}
</div>
{/if}
</div>
<style>
.entries-card {
background: var(--bg-mint);
position: relative;
overflow: hidden;
}
.entries-wrapper {
background: var(--bg-cream);
border: 3px solid var(--border-dark);
border-radius: var(--radius-sm);
padding: var(--space-sm);
max-height: 400px;
overflow-y: auto;
}
.entries-wrapper::-webkit-scrollbar {
width: 12px;
}
.entries-wrapper::-webkit-scrollbar-track {
background: var(--bg-cream);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
}
.entries-wrapper::-webkit-scrollbar-thumb {
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
}
.entry-item {
position: relative;
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: #FFFFFF;
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
margin-bottom: var(--space-sm);
box-shadow: 3px 3px 0px var(--border-dark);
transition: all var(--transition-fast);
animation: slideIn 0.3s ease-out forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes slideIn {
from {
transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.entry-item:hover {
transform: translate(-2px, -2px);
box-shadow: 5px 5px 0px var(--border-dark);
background: var(--pastel-yellow);
}
.entry-item:last-child {
margin-bottom: 0;
}
.entry-icon {
width: 48px;
height: 48px;
border: 3px solid var(--border-dark);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
box-shadow: 3px 3px 0px var(--border-dark);
flex-shrink: 0;
}
.entry-icon.short {
background: var(--pastel-pink);
}
.entry-icon.long {
background: var(--pastel-blue);
}
.entry-icon.decrement {
background: var(--pastel-coral);
}
.entry-details {
flex: 1;
}
.entry-type {
font-weight: 700;
color: var(--text-dark);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.entry-badge {
font-size: 1rem;
padding: 2px 8px;
background: var(--pastel-lavender);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
font-weight: 700;
}
.entry-badge.decrement {
background: var(--pastel-coral);
}
.entry-time {
font-size: 0.9rem;
color: var(--text-medium);
font-weight: 500;
margin-top: 2px;
}
.entry-tick {
font-size: 1.5rem;
color: var(--pastel-green);
font-weight: bold;
opacity: 0;
transition: opacity var(--transition-fast);
}
.entry-tick.decrement {
color: var(--pastel-coral);
}
.entry-item:hover .entry-tick {
opacity: 1;
}
.empty-state {
text-align: center;
padding: var(--space-2xl);
color: var(--text-medium);
position: relative;
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: var(--space-md);
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.empty-doodle {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 60px;
background-image:
radial-gradient(circle at 20% 50%, var(--pastel-pink) 3px, transparent 3px),
radial-gradient(circle at 40% 30%, var(--pastel-blue) 3px, transparent 3px),
radial-gradient(circle at 60% 60%, var(--pastel-yellow) 3px, transparent 3px),
radial-gradient(circle at 80% 40%, var(--pastel-green) 3px, transparent 3px);
background-size: 20px 20px;
background-repeat: repeat-x;
}
/* Loading and lazy loading styles */
.loading-trigger {
text-align: center;
padding: var(--space-lg);
margin-top: var(--space-md);
}
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
font-size: 1.1rem;
color: var(--text-medium);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.loading-spinner .emoji {
animation: none;
}
.load-more-hint {
font-size: 1rem;
color: var(--text-medium);
opacity: 0.7;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.all-loaded {
text-align: center;
padding: var(--space-lg);
color: var(--text-medium);
border-top: 2px dashed var(--border-dark);
margin-top: var(--space-md);
}
.all-loaded-icon {
font-size: 2rem;
margin-bottom: var(--space-sm);
}
.all-loaded p {
font-size: 0.95rem;
margin: 0;
}
/* Date range indicator */
.date-range-indicator {
display: inline-block;
font-size: 0.8rem;
color: var(--text-medium);
font-weight: 500;
background: var(--pastel-yellow);
border: 2px solid var(--border-dark);
border-radius: var(--radius-sm);
padding: 2px 8px;
margin-left: var(--space-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 2px 2px 0px var(--border-dark);
}
</style>

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,297 @@
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
// Ensure the data directory exists
const dataDir = join(process.cwd(), 'data');
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
// Create database connection
const dbPath = join(dataDir, 'dryer.db');
export const db = new Database(dbPath);
// Initialize database tables
export function initDatabase() {
// Create dry_entries table if it doesn't exist
const createTableQuery = `
CREATE TABLE IF NOT EXISTS dry_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('short', 'long')),
action TEXT NOT NULL DEFAULT 'increment' CHECK(action IN ('increment', 'decrement')),
created_at DATETIME DEFAULT (DATETIME('now', 'localtime'))
)
`;
// Create cost_settings table if it doesn't exist
const createCostSettingsTable = `
CREATE TABLE IF NOT EXISTS cost_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
eur_per_kwh REAL NOT NULL DEFAULT 0.3,
kwh_short_cycle REAL NOT NULL DEFAULT 1.0,
kwh_long_cycle REAL NOT NULL DEFAULT 1.6,
eur_short_cycle REAL,
eur_long_cycle REAL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
db.exec(createTableQuery);
db.exec(createCostSettingsTable);
// Insert default cost settings if they don't exist
const insertDefaultSettings = `
INSERT OR IGNORE INTO cost_settings (id, eur_per_kwh, kwh_short_cycle, kwh_long_cycle)
VALUES (1, 0.3, 1.0, 1.6)
`;
db.exec(insertDefaultSettings);
console.log('Database initialized successfully');
}
// Insert a new dry entry (for backward compatibility, defaults to increment)
export function insertDryEntry(type: 'short' | 'long') {
const stmt = db.prepare('INSERT INTO dry_entries (type, action, created_at) VALUES (?, ?, DATETIME(\'now\', \'localtime\'))');
const result = stmt.run(type, 'increment');
return result;
}
// Get count of dry entries by type (increments minus decrements) with optional date range
export function getDryCount(type?: 'short' | 'long', startDate?: string, endDate?: string) {
let query = `
SELECT
SUM(CASE WHEN action = 'increment' THEN 1 ELSE -1 END) as count
FROM dry_entries
WHERE 1=1
`;
const params: any[] = [];
if (type) {
query += ' AND type = ?';
params.push(type);
}
if (startDate) {
query += ' AND DATE(created_at, \'localtime\') >= DATE(?, \'localtime\')';
params.push(startDate);
}
if (endDate) {
query += ' AND DATE(created_at, \'localtime\') <= DATE(?, \'localtime\')';
params.push(endDate);
}
const stmt = db.prepare(query);
const result = stmt.get(...params) as { count: number };
return { count: Math.max(0, result.count || 0) }; // Ensure count is never negative
}
// Get recent dry entries with optional date range filtering
export function getRecentEntries(limit = 10, offset = 0, startDate?: string, endDate?: string) {
let query = `
SELECT id, type, action, created_at
FROM dry_entries
WHERE 1=1
`;
const params: any[] = [];
if (startDate) {
query += ' AND DATE(created_at, \'localtime\') >= DATE(?, \'localtime\')';
params.push(startDate);
}
if (endDate) {
query += ' AND DATE(created_at, \'localtime\') <= DATE(?, \'localtime\')';
params.push(endDate);
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.prepare(query);
return stmt.all(...params) as Array<{
id: number;
type: 'short' | 'long';
action: 'increment' | 'decrement';
created_at: string;
}>;
}
// Get total count of entries for pagination with optional date range
export function getTotalEntriesCount(startDate?: string, endDate?: string) {
let query = 'SELECT COUNT(*) as count FROM dry_entries WHERE 1=1';
const params: any[] = [];
if (startDate) {
query += ' AND DATE(created_at, \'localtime\') >= DATE(?, \'localtime\')';
params.push(startDate);
}
if (endDate) {
query += ' AND DATE(created_at, \'localtime\') <= DATE(?, \'localtime\')';
params.push(endDate);
}
const stmt = db.prepare(query);
return stmt.get(...params) as { count: number };
}
// Get statistics for charts (grouped by day) with optional date range
export function getDailyStats(days = 30, startDate?: string, endDate?: string) {
let query = `
SELECT
DATE(created_at, 'localtime') as date,
type,
SUM(CASE WHEN action = 'increment' THEN 1 ELSE -1 END) as count
FROM dry_entries
WHERE 1=1
`;
const params: any[] = [];
if (startDate && endDate) {
query += ' AND DATE(created_at, \'localtime\') >= DATE(?, \'localtime\') AND DATE(created_at, \'localtime\') <= DATE(?, \'localtime\')';
params.push(startDate, endDate);
} else {
// Fallback to original behavior for backward compatibility - use local time
query += ` AND created_at >= DATETIME('now', 'localtime', '-${days} days')`;
}
query += ' GROUP BY DATE(created_at, \'localtime\'), type ORDER BY date DESC';
const stmt = db.prepare(query);
const results = stmt.all(...params) as Array<{
date: string;
type: 'short' | 'long';
count: number;
}>;
// Ensure counts are never negative
return results.map(result => ({
...result,
count: Math.max(0, result.count || 0)
}));
}
// Get weekly statistics with optional date range
export function getWeeklyStats(weeks = 12, startDate?: string, endDate?: string) {
let query = `
SELECT
strftime('%Y-W%W', created_at, 'localtime') as week,
type,
SUM(CASE WHEN action = 'increment' THEN 1 ELSE -1 END) as count
FROM dry_entries
WHERE 1=1
`;
const params: any[] = [];
if (startDate && endDate) {
query += ' AND DATE(created_at, \'localtime\') >= DATE(?, \'localtime\') AND DATE(created_at, \'localtime\') <= DATE(?, \'localtime\')';
params.push(startDate, endDate);
} else {
// Fallback to original behavior for backward compatibility - use local time
query += ` AND created_at >= DATETIME('now', 'localtime', '-${weeks * 7} days')`;
}
query += ' GROUP BY strftime(\'%Y-W%W\', created_at, \'localtime\'), type ORDER BY week DESC';
const stmt = db.prepare(query);
const results = stmt.all(...params) as Array<{
week: string;
type: 'short' | 'long';
count: number;
}>;
// Ensure counts are never negative
return results.map(result => ({
...result,
count: Math.max(0, result.count || 0)
}));
}
// Increment dryer count (add normal dryer entry)
export function incrementDryCount(type: 'short' | 'long') {
return insertDryEntry(type);
}
// Decrement dryer count (add decrement entry)
export function decrementDryCount(type: 'short' | 'long') {
// Check current count before allowing decrement
const currentCount = getDryCount(type);
if (currentCount.count <= 0) {
throw new Error(`Cannot decrement ${type} cycle count: current count is 0`);
}
const stmt = db.prepare('INSERT INTO dry_entries (type, action, created_at) VALUES (?, ?, DATETIME(\'now\', \'localtime\'))');
const result = stmt.run(type, 'decrement');
return result;
}
// Cost settings functions
export interface CostSettings {
eur_per_kwh: number;
kwh_short_cycle: number;
kwh_long_cycle: number;
eur_short_cycle?: number;
eur_long_cycle?: number;
updated_at?: string;
}
// Get cost settings
export function getCostSettings(): CostSettings {
const stmt = db.prepare('SELECT eur_per_kwh, kwh_short_cycle, kwh_long_cycle, eur_short_cycle, eur_long_cycle, updated_at FROM cost_settings WHERE id = 1');
const result = stmt.get() as CostSettings | undefined;
return result || {
eur_per_kwh: 0.3,
kwh_short_cycle: 1.0,
kwh_long_cycle: 1.6,
eur_short_cycle: undefined,
eur_long_cycle: undefined,
};
}
// Update cost settings
export function updateCostSettings(settings: Partial<CostSettings>) {
const current = getCostSettings();
const merged = { ...current, ...settings };
const stmt = db.prepare(`
UPDATE cost_settings
SET eur_per_kwh = ?, kwh_short_cycle = ?, kwh_long_cycle = ?,
eur_short_cycle = ?, eur_long_cycle = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`);
return stmt.run(
merged.eur_per_kwh,
merged.kwh_short_cycle,
merged.kwh_long_cycle,
merged.eur_short_cycle || null,
merged.eur_long_cycle || null
);
}
// Calculate costs for given counts
export function calculateCosts(shortCount: number, longCount: number) {
const settings = getCostSettings();
const shortCostPerCycle = settings.eur_short_cycle || (settings.kwh_short_cycle * settings.eur_per_kwh);
const longCostPerCycle = settings.eur_long_cycle || (settings.kwh_long_cycle * settings.eur_per_kwh);
const totalShortCost = shortCount * shortCostPerCycle;
const totalLongCost = longCount * longCostPerCycle;
const totalCost = totalShortCost + totalLongCost;
return {
shortCostPerCycle,
longCostPerCycle,
totalShortCost,
totalLongCost,
totalCost,
settings
};
}
// Initialize the database when the module is imported
initDatabase();

View File

@ -0,0 +1,48 @@
// Server-Sent Events management for real-time updates
type SSEClient = {
id: string;
controller: ReadableStreamDefaultController;
};
// Store connected clients
const clients: Map<string, SSEClient> = new Map();
// Generate a unique client ID
function generateClientId(): string {
return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Add a new client
export function addClient(controller: ReadableStreamDefaultController): string {
const id = generateClientId();
clients.set(id, { id, controller });
console.log(`SSE Client connected: ${id}. Total clients: ${clients.size}`);
return id;
}
// Remove a client
export function removeClient(id: string): void {
clients.delete(id);
console.log(`SSE Client disconnected: ${id}. Total clients: ${clients.size}`);
}
// Broadcast an update to all connected clients
export function broadcastUpdate(event: { type: string; data: unknown }): void {
const message = `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
clients.forEach((client) => {
try {
client.controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
console.error(`Error sending to client ${client.id}:`, error);
// Remove disconnected clients
removeClient(client.id);
}
});
}
// Get the number of connected clients
export function getClientCount(): number {
return clients.size;
}

View File

@ -0,0 +1,29 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>🧺 Dryer Counter</title>
<meta name="description" content="Track your dryer cycles with style" />
</svelte:head>
<header class="header">
<div class="header-content">
<div class="logo">
<span>🧺</span>
<span>Dryer Counter</span>
</div>
<div class="status">
<span class="status-dot connected" id="connection-status"></span>
<span>Live</span>
</div>
</div>
</header>
<main>
{@render children()}
</main>

View File

@ -0,0 +1,23 @@
import type { PageServerLoad } from './$types';
import { getDryCount, getRecentEntries, getDailyStats, getWeeklyStats } from '$lib/server/db';
export const load: PageServerLoad = async () => {
// Get counts
const shortCount = getDryCount('short').count;
const longCount = getDryCount('long').count;
// Get recent entries (load 20 initially for better UX)
const recentEntries = getRecentEntries(20);
// Get stats for charts
const dailyStats = getDailyStats(14); // Last 14 days
const weeklyStats = getWeeklyStats(8); // Last 8 weeks
return {
shortCount,
longCount,
recentEntries,
dailyStats,
weeklyStats
};
};

View File

@ -0,0 +1,416 @@
<script lang="ts">
import Counter from '$lib/components/Counter.svelte';
import RecentEntries from '$lib/components/RecentEntries.svelte';
import DryChart from '$lib/components/DryChart.svelte';
import DateRangeSelector from '$lib/components/DateRangeSelector.svelte';
import Calculator from '$lib/components/Calculator.svelte';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
interface Entry {
id: number;
type: 'short' | 'long';
created_at: string;
}
interface DailyStat {
date: string;
type: 'short' | 'long';
count: number;
}
interface PageData {
shortCount: number;
longCount: number;
recentEntries: Entry[];
dailyStats: DailyStat[];
}
let { data }: { data: PageData } = $props();
// Reactive state
let shortCount = $state(data.shortCount);
let longCount = $state(data.longCount);
let recentEntries = $state(data.recentEntries);
let dailyStats = $state(data.dailyStats);
let isConnected = $state(false);
// Date range state
let startDate = $state('');
let endDate = $state('');
let eventSource: EventSource | null = null;
function connectSSE() {
if (!browser) return;
eventSource = new EventSource('/api/dry/stream');
eventSource.addEventListener('connected', (event) => {
console.log('SSE connected:', event.data);
isConnected = true;
updateConnectionStatus(true);
});
eventSource.addEventListener('new_entry', (event) => {
const eventData = JSON.parse(event.data);
// Note: SSE broadcasts unfiltered data, but we need to refresh filtered data
// based on current date range
// Refresh all data with current date range
fetchFilteredData();
});
eventSource.onerror = () => {
console.log('SSE error, reconnecting...');
isConnected = false;
updateConnectionStatus(false);
// Try to reconnect after 3 seconds
setTimeout(() => {
if (eventSource?.readyState === EventSource.CLOSED) {
connectSSE();
}
}, 3000);
};
}
function updateConnectionStatus(connected: boolean) {
const statusDot = document.getElementById('connection-status');
if (statusDot) {
statusDot.classList.toggle('connected', connected);
statusDot.classList.toggle('disconnected', !connected);
}
}
// Function to fetch all data with date range filtering
async function fetchFilteredData() {
try {
const url = new URL('/api/dry', window.location.origin);
if (startDate) url.searchParams.set('startDate', startDate);
if (endDate) url.searchParams.set('endDate', endDate);
const response = await fetch(url.toString());
const result = await response.json();
shortCount = result.shortCount;
longCount = result.longCount;
recentEntries = result.recentEntries;
// Also fetch stats for the chart
await fetchStats();
} catch (error) {
console.error('Error fetching filtered data:', error);
}
}
async function fetchStats() {
try {
const url = new URL('/api/dry/stats', window.location.origin);
url.searchParams.set('period', 'daily');
url.searchParams.set('days', '14');
if (startDate) url.searchParams.set('startDate', startDate);
if (endDate) url.searchParams.set('endDate', endDate);
const response = await fetch(url.toString());
const result = await response.json();
dailyStats = result.stats;
} catch (error) {
console.error('Error fetching stats:', error);
}
}
// Handle date range changes
function handleDateRangeChange(newStartDate: string, newEndDate: string) {
startDate = newStartDate;
endDate = newEndDate;
fetchFilteredData();
}
// Format date range for chart title (matching RecentEntries format)
function getChartDateRange(): string {
if (!startDate || !endDate) return '';
const start = new Date(startDate);
const end = new Date(endDate);
const formatDate = (date: Date) => {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short'
});
};
return `${formatDate(start)} - ${formatDate(end)}`;
}
async function handleIncrement(type: 'short' | 'long') {
try {
const response = await fetch('/api/dry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
action: 'increment'
})
});
if (!response.ok) {
throw new Error('Failed to increment count');
}
} catch (error) {
console.error('Error incrementing count:', error);
}
}
async function handleDecrement(type: 'short' | 'long') {
try {
const response = await fetch('/api/dry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
action: 'decrement'
})
});
if (!response.ok) {
throw new Error('Failed to decrement count');
}
} catch (error) {
console.error('Error decrementing count:', error);
}
}
onMount(() => {
connectSSE();
// Initial data fetch with default date range
// This will be handled by DateRangeSelector's initialization
});
onDestroy(() => {
if (eventSource) {
eventSource.close();
}
});
// Update state when data changes (e.g., on navigation)
$effect(() => {
shortCount = data.shortCount;
longCount = data.longCount;
recentEntries = data.recentEntries;
dailyStats = data.dailyStats;
});
</script>
<div class="container">
<!-- Date Range Selector -->
<section class="date-range-section">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onDateRangeChange={handleDateRangeChange}
/>
</section>
<!-- Counters Section -->
<section class="counters-section">
<div class="grid grid-2">
<Counter
type="short"
count={shortCount}
onIncrement={() => handleIncrement('short')}
onDecrement={() => handleDecrement('short')}
/>
<Counter
type="long"
count={longCount}
onIncrement={() => handleIncrement('long')}
onDecrement={() => handleDecrement('long')}
/>
</div>
</section>
<!-- Total Count -->
<section class="total-section">
<div class="card total-card">
<div class="total-content">
<span class="total-icon">🧺</span>
<div class="total-info">
<span class="total-number">{shortCount + longCount}</span>
<span class="total-label">Total Dry Cycles</span>
</div>
</div>
</div>
</section>
<!-- Calculator Section -->
<section class="calculator-section">
<Calculator
shortCount={shortCount}
longCount={longCount}
startDate={startDate}
endDate={endDate}
/>
</section>
<!-- Chart Section -->
<section class="chart-section">
<DryChart
stats={dailyStats}
title="Tägliche Trockengänge"
showDateRange={startDate && endDate}
dateRange={{ start: startDate, end: endDate }}
/>
</section>
<!-- Recent Entries Section -->
<section class="entries-section">
<RecentEntries
entries={recentEntries}
showDateRange={startDate && endDate}
dateRange={{ start: startDate, end: endDate }}
/>
</section>
<!-- API Info Section -->
<section class="api-section">
<div class="card">
<div class="section-title">
<span>🔌</span>
<h2>Home Assistant Integration</h2>
</div>
<div class="api-info">
<p>Send POST requests to log dry cycles:</p>
<code class="api-endpoint">POST /api/dry</code>
<div class="api-examples">
<div class="api-example">
<span class="example-label">Short cycle:</span>
<code>{`{ "type": "short" }`}</code>
</div>
<div class="api-example">
<span class="example-label">Long cycle:</span>
<code>{`{ "type": "long" }`}</code>
</div>
</div>
</div>
</div>
</section>
</div>
<style>
.container {
padding-bottom: var(--space-2xl);
}
section {
margin-bottom: var(--space-xl);
}
.date-range-section {
margin-bottom: var(--space-lg);
}
.counters-section {
margin-bottom: var(--space-lg);
}
.calculator-section {
margin-bottom: var(--space-xl);
}
/* Total Card */
.total-card {
background: linear-gradient(135deg, var(--pastel-green) 0%, var(--pastel-mint) 100%);
}
.total-content {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-lg);
}
.total-icon {
font-size: 3rem;
}
.total-info {
display: flex;
flex-direction: column;
}
.total-number {
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.total-label {
font-size: 1rem;
color: var(--text-medium);
text-transform: uppercase;
letter-spacing: 1px;
}
/* API Info */
.api-info {
font-size: 0.95rem;
}
.api-info p {
margin-bottom: var(--space-md);
color: var(--text-medium);
}
.api-endpoint {
display: block;
background: var(--bg-section);
padding: var(--space-md);
border-radius: var(--radius-sm);
font-family: monospace;
font-size: 1rem;
margin-bottom: var(--space-lg);
}
.api-examples {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.api-example {
display: flex;
align-items: center;
gap: var(--space-md);
}
.example-label {
font-weight: 500;
min-width: 100px;
}
.api-example code {
background: var(--bg-section);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-family: monospace;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.total-content {
flex-direction: column;
text-align: center;
}
.api-example {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import { calculateCosts, getDryCount } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
try {
// Get query parameters
const startDate = url.searchParams.get('startDate');
const endDate = url.searchParams.get('endDate');
// Get counts from database with optional date range filtering
const shortResult = getDryCount('short', startDate || undefined, endDate || undefined);
const longResult = getDryCount('long', startDate || undefined, endDate || undefined);
const actualShortCount = shortResult.count;
const actualLongCount = longResult.count;
// Calculate costs
const costs = calculateCosts(actualShortCount, actualLongCount);
return json({
...costs,
shortCount: actualShortCount,
longCount: actualLongCount,
dateRange: startDate && endDate ? { start: startDate, end: endDate } : null
});
} catch (err) {
console.error('Error calculating costs:', err);
return error(500, 'Internal server error');
}
};

View File

@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import { getCostSettings, updateCostSettings } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
try {
const settings = getCostSettings();
return json(settings);
} catch (err) {
console.error('Error fetching cost settings:', err);
return error(500, 'Internal server error');
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
// Validate required fields
if (body.eur_per_kwh === undefined || body.kwh_short_cycle === undefined || body.kwh_long_cycle === undefined) {
return error(400, 'Missing required fields: eur_per_kwh, kwh_short_cycle, kwh_long_cycle');
}
// Validate numeric values
if (isNaN(body.eur_per_kwh) || isNaN(body.kwh_short_cycle) || isNaN(body.kwh_long_cycle)) {
return error(400, 'All values must be numbers');
}
if (body.eur_per_kwh < 0 || body.kwh_short_cycle < 0 || body.kwh_long_cycle < 0) {
return error(400, 'All values must be non-negative');
}
if (body.eur_short_cycle && isNaN(body.eur_short_cycle)) {
return error(400, 'eur_short_cycle must be a number');
}
if (body.eur_long_cycle && isNaN(body.eur_long_cycle)) {
return error(400, 'eur_long_cycle must be a number');
}
// Update cost settings
const settings = {
eur_per_kwh: parseFloat(body.eur_per_kwh),
kwh_short_cycle: parseFloat(body.kwh_short_cycle),
kwh_long_cycle: parseFloat(body.kwh_long_cycle),
eur_short_cycle: body.eur_short_cycle ? parseFloat(body.eur_short_cycle) : undefined,
eur_long_cycle: body.eur_long_cycle ? parseFloat(body.eur_long_cycle) : undefined
};
updateCostSettings(settings);
return json({ success: true, settings });
} catch (err) {
console.error('Error updating cost settings:', err);
return error(500, 'Internal server error');
}
};

View File

@ -0,0 +1,92 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { insertDryEntry, incrementDryCount, decrementDryCount, getDryCount, getRecentEntries } from '$lib/server/db';
import { broadcastUpdate } from '$lib/server/sse';
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { type, action } = body;
// Validate type
if (type !== 'short' && type !== 'long') {
return json(
{ error: 'Invalid type. Must be "short" or "long".' },
{ status: 400 }
);
}
// Validate action
if (!action || !['increment', 'decrement'].includes(action)) {
return json(
{ error: 'Invalid action. Must be "increment" or "decrement".' },
{ status: 400 }
);
}
// Perform the action
let result;
if (action === 'increment') {
result = incrementDryCount(type);
} else {
result = decrementDryCount(type);
}
// Get updated counts (always broadcast full data, not filtered by date range)
const shortCount = getDryCount('short').count;
const longCount = getDryCount('long').count;
const recentEntries = getRecentEntries(20);
// Broadcast update to all connected clients
broadcastUpdate({
type: 'new_entry',
data: {
shortCount,
longCount,
recentEntries
}
});
return json({
success: true,
action,
type,
id: result.lastInsertRowid,
shortCount,
longCount
});
} catch (error) {
console.error('Error handling dry entry:', error);
return json(
{ error: 'Failed to handle dry entry' },
{ status: 500 }
);
}
};
export const GET: RequestHandler = async ({ url }) => {
try {
const startDate = url.searchParams.get('startDate');
const endDate = url.searchParams.get('endDate');
const shortCount = getDryCount('short', startDate, endDate).count;
const longCount = getDryCount('long', startDate, endDate).count;
const recentEntries = getRecentEntries(20, 0, startDate, endDate);
return json({
shortCount,
longCount,
recentEntries,
dateRange: {
startDate,
endDate
}
});
} catch (error) {
console.error('Error fetching dry stats:', error);
return json(
{ error: 'Failed to fetch dry stats' },
{ status: 500 }
);
}
};

View File

@ -0,0 +1,48 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRecentEntries, getTotalEntriesCount } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
try {
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const startDate = url.searchParams.get('startDate');
const endDate = url.searchParams.get('endDate');
// Validate parameters
if (limit < 1 || limit > 100) {
return json(
{ error: 'Limit must be between 1 and 100' },
{ status: 400 }
);
}
if (offset < 0) {
return json(
{ error: 'Offset must be non-negative' },
{ status: 400 }
);
}
const entries = getRecentEntries(limit, offset, startDate, endDate);
const totalCount = getTotalEntriesCount(startDate, endDate);
return json({
entries,
totalCount: totalCount.count,
hasMore: offset + entries.length < totalCount.count,
currentOffset: offset,
currentLimit: limit,
dateRange: {
startDate,
endDate
}
});
} catch (error) {
console.error('Error fetching paginated entries:', error);
return json(
{ error: 'Failed to fetch entries' },
{ status: 500 }
);
}
};

View File

@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getDailyStats, getWeeklyStats, getDryCount } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
try {
const period = url.searchParams.get('period') || 'daily';
const days = parseInt(url.searchParams.get('days') || '30', 10);
const weeks = parseInt(url.searchParams.get('weeks') || '12', 10);
const startDate = url.searchParams.get('startDate');
const endDate = url.searchParams.get('endDate');
let stats;
if (period === 'weekly') {
stats = getWeeklyStats(weeks, startDate, endDate);
} else {
stats = getDailyStats(days, startDate, endDate);
}
// Get total counts (respect date range if provided)
const totalShort = getDryCount('short', startDate, endDate).count;
const totalLong = getDryCount('long', startDate, endDate).count;
return json({
stats,
totals: {
short: totalShort,
long: totalLong,
total: totalShort + totalLong
},
dateRange: {
startDate,
endDate
}
});
} catch (error) {
console.error('Error fetching stats:', error);
return json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
};

View File

@ -0,0 +1,30 @@
import type { RequestHandler } from './$types';
import { addClient, removeClient } from '$lib/server/sse';
export const GET: RequestHandler = async () => {
let clientId: string;
const stream = new ReadableStream({
start(controller) {
clientId = addClient(controller);
// Send initial connection message
const connectMessage = `event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
},
cancel() {
if (clientId) {
removeClient(clientId);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable buffering for nginx
}
});
};

View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@ -0,0 +1,20 @@
import { mdsvex } from 'mdsvex';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: [vitePreprocess(), mdsvex()],
kit: {
// Using node adapter for SQLite support
adapter: adapter({
// Optional: specify where to build the app
out: 'build'
})
},
extensions: ['.svelte', '.svx']
};
export default config;

View File

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});

22
pocketbase/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM alpine:latest
ARG PB_VERSION=0.34.2
RUN apk add --no-cache \
unzip \
ca-certificates
# download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# uncomment to copy the local pb_migrations dir into the image
# COPY ./pb_migrations /pb/pb_migrations
# uncomment to copy the local pb_hooks dir into the image
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]