feat: dryer counter initial commot
This commit is contained in:
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal 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
23
dryer-counter/.gitignore
vendored
Normal 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
1
dryer-counter/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
47
dryer-counter/Dockerfile
Normal file
47
dryer-counter/Dockerfile
Normal 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
38
dryer-counter/README.md
Normal 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
BIN
dryer-counter/data/dryer.db
Normal file
Binary file not shown.
32
dryer-counter/package.json
Normal file
32
dryer-counter/package.json
Normal 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
1551
dryer-counter/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
dryer-counter/pnpm-workspace.yaml
Normal file
3
dryer-counter/pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
432
dryer-counter/src/app.css
Normal file
432
dryer-counter/src/app.css
Normal 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
13
dryer-counter/src/app.d.ts
vendored
Normal 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 {};
|
||||
11
dryer-counter/src/app.html
Normal file
11
dryer-counter/src/app.html
Normal 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>
|
||||
1
dryer-counter/src/lib/assets/favicon.svg
Normal file
1
dryer-counter/src/lib/assets/favicon.svg
Normal 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 |
541
dryer-counter/src/lib/components/Calculator.svelte
Normal file
541
dryer-counter/src/lib/components/Calculator.svelte
Normal 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>
|
||||
145
dryer-counter/src/lib/components/Counter.svelte
Normal file
145
dryer-counter/src/lib/components/Counter.svelte
Normal 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>
|
||||
418
dryer-counter/src/lib/components/DateRangeSelector.svelte
Normal file
418
dryer-counter/src/lib/components/DateRangeSelector.svelte
Normal 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>
|
||||
313
dryer-counter/src/lib/components/DryChart.svelte
Normal file
313
dryer-counter/src/lib/components/DryChart.svelte
Normal 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>
|
||||
506
dryer-counter/src/lib/components/RecentEntries.svelte
Normal file
506
dryer-counter/src/lib/components/RecentEntries.svelte
Normal 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>
|
||||
1
dryer-counter/src/lib/index.ts
Normal file
1
dryer-counter/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
297
dryer-counter/src/lib/server/db.ts
Normal file
297
dryer-counter/src/lib/server/db.ts
Normal 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();
|
||||
48
dryer-counter/src/lib/server/sse.ts
Normal file
48
dryer-counter/src/lib/server/sse.ts
Normal 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;
|
||||
}
|
||||
29
dryer-counter/src/routes/+layout.svelte
Normal file
29
dryer-counter/src/routes/+layout.svelte
Normal 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>
|
||||
23
dryer-counter/src/routes/+page.server.ts
Normal file
23
dryer-counter/src/routes/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
416
dryer-counter/src/routes/+page.svelte
Normal file
416
dryer-counter/src/routes/+page.svelte
Normal 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>
|
||||
31
dryer-counter/src/routes/api/calculate-costs/+server.ts
Normal file
31
dryer-counter/src/routes/api/calculate-costs/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
57
dryer-counter/src/routes/api/cost-settings/+server.ts
Normal file
57
dryer-counter/src/routes/api/cost-settings/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
92
dryer-counter/src/routes/api/dry/+server.ts
Normal file
92
dryer-counter/src/routes/api/dry/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
48
dryer-counter/src/routes/api/dry/entries/+server.ts
Normal file
48
dryer-counter/src/routes/api/dry/entries/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
43
dryer-counter/src/routes/api/dry/stats/+server.ts
Normal file
43
dryer-counter/src/routes/api/dry/stats/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
30
dryer-counter/src/routes/api/dry/stream/+server.ts
Normal file
30
dryer-counter/src/routes/api/dry/stream/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
3
dryer-counter/static/robots.txt
Normal file
3
dryer-counter/static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
20
dryer-counter/svelte.config.js
Normal file
20
dryer-counter/svelte.config.js
Normal 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;
|
||||
20
dryer-counter/tsconfig.json
Normal file
20
dryer-counter/tsconfig.json
Normal 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
|
||||
}
|
||||
6
dryer-counter/vite.config.ts
Normal file
6
dryer-counter/vite.config.ts
Normal 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
22
pocketbase/Dockerfile
Normal 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"]
|
||||
Reference in New Issue
Block a user