diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..456d90c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# Daily Timer build outputs
+build/bin/
+bin/
+
+# Frontend
+frontend/node_modules/
+frontend/dist/
+
+# Go
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary
+*.test
+
+# Output of go coverage
+*.out
+
+# Dependency directories
+vendor/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# macOS
+.DS_Store
+
+# Logs
+*.log
+
+# Local development
+.env.local
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..fffd4e7
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,21 @@
+# golangci-lint configuration
+# https://golangci-lint.run/usage/configuration/
+
+version: '2'
+
+run:
+ timeout: 5m
+ tests: true
+
+linters:
+ enable:
+ - errcheck
+ - staticcheck
+ - govet
+ - ineffassign
+ - unused
+ - misspell
+ - unconvert
+ - goconst
+ - gocyclo
+ - dupl
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..fa53d0a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,74 @@
+.PHONY: dev build clean install frontend
+
+# Development
+dev:
+ wails dev
+
+# Build for macOS
+build:
+ wails build -clean
+
+# Build for macOS (universal binary)
+build-universal:
+ wails build -clean -platform darwin/universal
+
+# Install frontend dependencies
+frontend:
+ cd frontend && npm install
+
+# Generate Go bindings
+generate:
+ wails generate module
+
+# Clean build artifacts
+clean:
+ rm -rf build/bin
+ rm -rf frontend/dist
+ rm -rf frontend/node_modules
+
+# Run tests
+test:
+ go test ./...
+
+# Format code
+fmt:
+ go fmt ./...
+ cd frontend && npm run format 2>/dev/null || true
+
+# Lint
+lint:
+ golangci-lint run ./...
+
+# Install dependencies
+deps:
+ go mod download
+ go mod tidy
+ cd frontend && npm install
+
+# Initialize project (first time setup)
+init: deps frontend
+ @echo "Project initialized. Run 'make dev' to start development."
+
+# Help
+help:
+ @echo "Daily Timer - Makefile Commands"
+ @echo ""
+ @echo "Development:"
+ @echo " make dev - Start development server with hot reload"
+ @echo " make build - Build production binary for macOS"
+ @echo " make build-universal - Build universal binary (Intel + Apple Silicon)"
+ @echo ""
+ @echo "Setup:"
+ @echo " make init - Initialize project (install all dependencies)"
+ @echo " make deps - Install Go and frontend dependencies"
+ @echo " make frontend - Install frontend dependencies only"
+ @echo ""
+ @echo "Maintenance:"
+ @echo " make clean - Remove build artifacts"
+ @echo " make fmt - Format Go and frontend code"
+ @echo " make lint - Run linters"
+ @echo " make test - Run tests"
+ @echo ""
+ @echo "Misc:"
+ @echo " make generate - Regenerate Wails bindings"
+ @echo " make help - Show this help"
diff --git a/README.md b/README.md
index e69de29..d0c9b2a 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,160 @@
+# Daily Timer
+
+A desktop application for managing daily standup meetings with time tracking, participant management, and statistics.
+
+## Features
+
+- ⏱️ **Timer Display** - Large, visible timer for current speaker with progress bar
+- 👥 **Participant Management** - Predefined list with configurable time limits per person
+- 🔄 **Speaking Queue** - Visual queue with ability to reorder, skip, or move speakers
+- ⏭️ **Skip Function** - Move unprepared participants to end of queue
+- ✅ **Attendance Tracking** - Mark participants as present/absent before meeting
+- ⚠️ **Time Warnings** - Visual and audio alerts when time is running out
+- 📊 **Statistics** - Track meeting duration, overtime, and individual performance
+- 📈 **History** - View past meetings with detailed breakdowns
+- 💾 **Export** - Export data in JSON or CSV format
+- 🔊 **Sound Notifications** - Configurable audio alerts
+
+## Screenshots
+
+_(Coming soon)_
+
+## Requirements
+
+- macOS 10.15+ (Catalina or later)
+- Go 1.21+
+- Node.js 18+
+- Wails CLI v2
+
+## Installation
+
+### Install Wails CLI
+
+```bash
+go install github.com/wailsapp/wails/v2/cmd/wails@latest
+```
+
+### Clone and Build
+
+```bash
+git clone https://github.com/your-username/daily-timer.git
+cd daily-timer
+
+# Initialize project
+make init
+
+# Development mode
+make dev
+
+# Build for production
+make build
+```
+
+The built application will be in `build/bin/`.
+
+## Usage
+
+### Setup Meeting
+
+1. **Add Participants** - Enter names and set individual time limits
+2. **Set Meeting Limit** - Configure total meeting duration (default: 15 minutes)
+3. **Arrange Order** - Drag to reorder speaking sequence
+4. **Mark Attendance** - Toggle present/absent status
+
+### During Meeting
+
+1. Click **Start Meeting** to begin
+2. Timer shows current speaker with countdown
+3. Click **Next Speaker** to advance (or press ⌘N)
+4. Click **Skip** to move current speaker to end of queue
+5. Use **Pause/Resume** for interruptions
+6. Click **Stop** to end meeting early
+
+### Keyboard Shortcuts
+
+| Key | Action |
+| ----- | ------------ |
+| ⌘N | Next Speaker |
+| ⌘S | Skip Speaker |
+| Space | Pause/Resume |
+| ⌘Q | Stop Meeting |
+
+### Statistics
+
+- View historical meeting data in the **History** tab
+- Filter by date range
+- Export to JSON or CSV for external analysis
+- Track participant overtime frequency and average speaking time
+
+## Configuration
+
+Settings are stored in `~/Library/Application Support/DailyTimer/`:
+
+- `daily-timer.db` - SQLite database with all data
+- `sounds/` - Custom sound files (optional)
+
+### Custom Sounds
+
+Place custom MP3 files in the sounds directory:
+
+- `warning.mp3` - Plays when time is running low
+- `timeup.mp3` - Plays when speaker time expires
+- `meeting_end.mp3` - Plays when meeting ends
+
+## Development
+
+### Project Structure
+
+```
+daily-timer/
+├── main.go # Wails application entry point
+├── internal/
+│ ├── app/ # Application logic and Wails bindings
+│ ├── models/ # Data models
+│ ├── storage/ # SQLite database layer
+│ └── timer/ # Timer logic with events
+├── frontend/
+│ ├── src/
+│ │ ├── App.svelte # Main application
+│ │ └── components/ # UI components
+│ └── wailsjs/ # Generated Go bindings
+├── wails.json # Wails configuration
+├── Makefile # Build commands
+└── README.md
+```
+
+### Commands
+
+```bash
+make dev # Start development with hot reload
+make build # Build production binary
+make test # Run tests
+make fmt # Format code
+make lint # Run linters
+make clean # Clean build artifacts
+```
+
+## Roadmap
+
+- [ ] Drag-and-drop participant reordering
+- [ ] Telegram integration (send meeting summary)
+- [ ] Calendar integration (auto-schedule)
+- [ ] Team templates
+- [ ] Cloud sync
+- [ ] Windows/Linux support
+
+## License
+
+MIT License - see [LICENSE](LICENSE) for details.
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Run tests and linting
+5. Submit a pull request
+
+## Support
+
+For issues and feature requests, please use GitHub Issues.
diff --git a/build/appicon.png b/build/appicon.png
new file mode 100644
index 0000000..63617fe
Binary files /dev/null and b/build/appicon.png differ
diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist
new file mode 100644
index 0000000..14121ef
--- /dev/null
+++ b/build/darwin/Info.dev.plist
@@ -0,0 +1,68 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{.Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..92101f4
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Daily Timer
+
+
+
+
+
+
+
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
new file mode 100644
index 0000000..61f5c0c
--- /dev/null
+++ b/frontend/jsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "module": "ESNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["svelte"]
+ },
+ "include": ["src/**/*"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..6524780
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1299 @@
+{
+ "name": "daily-timer-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "daily-timer-frontend",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.1.1",
+ "svelte": "^4.2.18",
+ "vite": "^5.4.2"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+ "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+ "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+ "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+ "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+ "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+ "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+ "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+ "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+ "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+ "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+ "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+ "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+ "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+ "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+ "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+ "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+ "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+ "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+ "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+ "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+ "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
+ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
+ "debug": "^4.3.4",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.10",
+ "svelte-hmr": "^0.16.0",
+ "vitefu": "^0.2.5"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
+ "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/code-red": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
+ "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.10.0",
+ "estree-walker": "^3.0.3",
+ "periscopic": "^3.1.0"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
+ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.30",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.0.30",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
+ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/periscopic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
+ "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^3.0.0",
+ "is-reference": "^3.0.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.57.1",
+ "@rollup/rollup-android-arm64": "4.57.1",
+ "@rollup/rollup-darwin-arm64": "4.57.1",
+ "@rollup/rollup-darwin-x64": "4.57.1",
+ "@rollup/rollup-freebsd-arm64": "4.57.1",
+ "@rollup/rollup-freebsd-x64": "4.57.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+ "@rollup/rollup-linux-arm64-musl": "4.57.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+ "@rollup/rollup-linux-loong64-musl": "4.57.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-musl": "4.57.1",
+ "@rollup/rollup-openbsd-x64": "4.57.1",
+ "@rollup/rollup-openharmony-arm64": "4.57.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+ "@rollup/rollup-win32-x64-gnu": "4.57.1",
+ "@rollup/rollup-win32-x64-msvc": "4.57.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "4.2.20",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.9.0",
+ "aria-query": "^5.3.0",
+ "axobject-query": "^4.0.0",
+ "code-red": "^1.0.3",
+ "css-tree": "^2.3.1",
+ "estree-walker": "^3.0.3",
+ "is-reference": "^3.0.1",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.4",
+ "periscopic": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/svelte-hmr": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
+ "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^12.20 || ^14.13.1 || >= 16"
+ },
+ "peerDependencies": {
+ "svelte": "^3.19.0 || ^4.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
+ "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..37f9a9f
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "daily-timer-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.1.1",
+ "svelte": "^4.2.18",
+ "vite": "^5.4.2"
+ }
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
new file mode 100755
index 0000000..fa7501e
--- /dev/null
+++ b/frontend/package.json.md5
@@ -0,0 +1 @@
+719a41f9088f999bf7b87d245f1e5231
\ No newline at end of file
diff --git a/frontend/public/sounds/README.md b/frontend/public/sounds/README.md
new file mode 100644
index 0000000..bad70bd
--- /dev/null
+++ b/frontend/public/sounds/README.md
@@ -0,0 +1,10 @@
+# Sound Files
+
+Place custom MP3 sound files here:
+
+- `warning.mp3` - Plays when speaker time is running low (30 seconds by default)
+- `timeup.mp3` - Plays when speaker time expires
+- `meeting_end.mp3` - Plays when meeting ends
+
+The application uses the Web Audio API to play sounds from the frontend.
+Default sounds are embedded, but you can override them with custom files.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..d962469
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,466 @@
+
+
+
+
+
+
+
+
+ {#if currentView === 'main'}
+ {#if meetingActive && timerState}
+
+
+
+
meetingActive = false} />
+
+ {:else if participants.length > 0}
+
+
{currentTime}
+
{$t('timer.readyToStart')}
+
{$t('timer.registeredParticipants')}: {participants.length}
+
+
+
+ {:else}
+
+
{currentTime}
+
{$t('timer.noParticipants')}
+
{$t('timer.goToParticipants')}
+
+
+ {/if}
+ {:else if currentView === 'setup'}
+
+ {:else if currentView === 'history'}
+
+ {:else if currentView === 'settings'}
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/components/Controls.svelte b/frontend/src/components/Controls.svelte
new file mode 100644
index 0000000..3fe0787
--- /dev/null
+++ b/frontend/src/components/Controls.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
+ {#if hasQueue}
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/History.svelte b/frontend/src/components/History.svelte
new file mode 100644
index 0000000..428c64c
--- /dev/null
+++ b/frontend/src/components/History.svelte
@@ -0,0 +1,572 @@
+
+
+
+
+
+ {#if loading}
+
{$t('common.loading')}
+ {:else}
+ {#if stats}
+
+ {$t('participants.stats')}
+
+
+
+
{stats.totalSessions}
+
{$t('participants.totalMeetings')}
+
+
+
+
{formatTime(Math.round(stats.averageMeetingTime))}
+
{$t('history.avgTime')}
+
+
+
+
{stats.overtimePercentage.toFixed(0)}%
+
{$t('history.overtimeRate')}
+
+
+
+
{stats.averageAttendance.toFixed(1)}
+
{$t('history.avgAttendance')}
+
+
+
+ {#if stats.participantBreakdown?.length > 0}
+ {$t('history.participantBreakdown')}
+
+
+ {#each stats.participantBreakdown as p}
+
+ {p.name}
+ {p.sessionsAttended}
+ {formatTime(Math.round(p.averageSpeakingTime))}
+ 0}>{p.overtimeCount}
+ {p.attendanceRate.toFixed(0)}%
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+ {$t('history.recentSessions')}
+
+ {#if sessions.length === 0}
+ {$t('history.noSessions')}
+ {:else}
+ {#each sessions as session}
+ 900}>
+
+
+ {#if session.participantLogs?.length > 0}
+
+ {#each session.participantLogs as log}
+
+ #{log.order}
+ {log.participant?.name || 'Unknown'}
+ {formatTime(log.duration)}
+ {#if log.overtime}
+ ⚠️
+ {/if}
+ {#if log.skipped}
+ ⏭️
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+
+
+{#if deletingSessionId !== null}
+
+ deletingSessionId = null}>
+
+
+
{$t('history.confirmDeleteTitle')}
+
{$t('history.confirmDeleteSession')}
+
+
+
+
+
+
+{/if}
+
+
+{#if showDeleteAllConfirm}
+
+ showDeleteAllConfirm = false}>
+
+
+
{$t('history.confirmDeleteAllTitle')}
+
{$t('history.confirmDeleteAll')}
+
+
+
+
+
+
+{/if}
+
+
diff --git a/frontend/src/components/ParticipantList.svelte b/frontend/src/components/ParticipantList.svelte
new file mode 100644
index 0000000..48f48cf
--- /dev/null
+++ b/frontend/src/components/ParticipantList.svelte
@@ -0,0 +1,166 @@
+
+
+
+
{$t('timer.participants')}
+
+ {#if allSpeakers.length > 0}
+
+ {#each allSpeakers as speaker}
+ -
+ {speaker.order}
+ {speaker.name}
+ {Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}
+ {#if speaker.status === 'pending' || speaker.status === 'skipped'}
+
+ {/if}
+
+ {/each}
+
+ {:else}
+
—
+ {/if}
+
+
+
diff --git a/frontend/src/components/Settings.svelte b/frontend/src/components/Settings.svelte
new file mode 100644
index 0000000..3bb30a9
--- /dev/null
+++ b/frontend/src/components/Settings.svelte
@@ -0,0 +1,372 @@
+
+
+
+ {#if loading}
+
{$t('common.loading')}
+ {:else if !meeting || !settings}
+
Failed to load settings. Please restart the app.
+ {:else}
+
+ {$t('settings.language')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/Setup.svelte b/frontend/src/components/Setup.svelte
new file mode 100644
index 0000000..3dbdefb
--- /dev/null
+++ b/frontend/src/components/Setup.svelte
@@ -0,0 +1,826 @@
+
+
+
+
+
+
+
+
+ e.key === 'Enter' && handleAddParticipant()}
+ />
+
+ {$t('setup.minutes')}
+
+
+
+ {#if loading}
+
{$t('common.loading')}
+ {:else if participants.length === 0}
+
+
{$t('setup.noParticipants')}
+
+ {:else}
+
+
{$t('timer.queue')}
+
☰ {$t('setup.dragHint')}
+
+
+ {#each selectedOrder as id, i}
+ {@const p = getParticipant(id)}
+ {#if p}
+ - handleDragStart(e, id)}
+ on:dragend={handleDragEnd}
+ on:dragover={(e) => handleDragOver(e, id)}
+ on:dragleave={handleDragLeave}
+ on:drop={(e) => handleDrop(e, id)}
+ >
+ ☰
+
+ {i + 1}
+
+
+
+ {p.name}
+ {Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}
+
+
+
+
+ {/if}
+ {/each}
+
+
+
+ {#if editingId !== null}
+
+
+
+
+
{$t('participants.edit')}
+
+
+ {
+ if (e.key === 'Enter') saveEdit()
+ if (e.key === 'Escape') cancelEdit()
+ }} />
+
+
+
+ {
+ if (e.key === 'Enter') saveEdit()
+ if (e.key === 'Escape') cancelEdit()
+ }} />
+
+
+
+
+
+
+
+ {/if}
+
+
+ {$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}
+ ≈ {formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/Timer.svelte b/frontend/src/components/Timer.svelte
new file mode 100644
index 0000000..690f8df
--- /dev/null
+++ b/frontend/src/components/Timer.svelte
@@ -0,0 +1,464 @@
+
+
+
+
+
+
+ {#if timerState?.currentSpeaker}
+ Сейчас говорит: {timerState.currentSpeaker}
+ {:else}
+ {$t('timer.noSpeaker')}
+ {/if}
+
+
+
+
+
+ {speakerTime}
+ /
+ {speakerLimit}
+
+
+
+
+ {#if timerState?.speakerOvertime}
+
⏰
+ {:else if timerState?.warning}
+
⚠️
+ {/if}
+
+
+
+
{$t('timer.totalTime')}
+
+ {meetingTime} / {meetingLimit}
+
+
+
+
+
+ Speaker {timerState?.speakingOrder || 0} of {timerState?.totalSpeakers || 0}
+ {#if timerState?.paused}
+ PAUSED
+ {/if}
+
+
+
+
diff --git a/frontend/src/lib/i18n.js b/frontend/src/lib/i18n.js
new file mode 100644
index 0000000..ee68b96
--- /dev/null
+++ b/frontend/src/lib/i18n.js
@@ -0,0 +1,318 @@
+import { writable, derived } from 'svelte/store';
+
+export const locale = writable('ru');
+
+export const translations = {
+ ru: {
+ // Navigation
+ nav: {
+ timer: 'Таймер',
+ setup: 'Участники',
+ history: 'История',
+ settings: 'Настройки',
+ },
+
+ // Setup page
+ setup: {
+ title: 'Название собрания',
+ participants: 'Участники',
+ addParticipant: 'Добавить участника',
+ namePlaceholder: 'Имя участника',
+ noParticipants: 'Добавьте участников для начала собрания',
+ selectAll: 'Выбрать всех',
+ deselectAll: 'Снять выбор',
+ startMeeting: 'Начать собрание',
+ speakerTime: 'Время на спикера',
+ totalTime: 'Общее время',
+ minutes: 'мин',
+ unlimited: 'Без ограничения',
+ dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
+ },
+
+ // Timer page
+ timer: {
+ currentSpeaker: 'Текущий спикер',
+ speakerTime: 'Время спикера',
+ totalTime: 'Общее время',
+ remaining: 'Осталось',
+ queue: 'Очередь',
+ participants: 'Участники',
+ finished: 'Выступили',
+ noSpeaker: 'Нет спикера',
+ noActiveMeeting: 'Собрание не запущено',
+ goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
+ readyToStart: 'Всё готово к началу',
+ editParticipants: 'Редактировать участников',
+ noParticipants: 'Участники не добавлены',
+ registeredParticipants: 'Зарегистрированные участники',
+ },
+
+ // Controls
+ controls: {
+ next: 'Следующий',
+ skip: 'Пропустить',
+ pause: 'Пауза',
+ resume: 'Продолжить',
+ stop: 'Завершить',
+ },
+
+ // History page
+ history: {
+ title: 'История собраний',
+ noHistory: 'История пуста',
+ date: 'Дата',
+ duration: 'Длительность',
+ participants: 'Участники',
+ avgTime: 'Среднее время',
+ export: 'Экспорт',
+ exportJSON: 'Экспорт JSON',
+ exportCSV: 'Экспорт CSV',
+ delete: 'Удалить',
+ deleteAll: 'Удалить историю',
+ deleteSession: 'Удалить запись',
+ confirmDelete: 'Удалить эту запись?',
+ confirmDeleteTitle: 'Подтверждение удаления',
+ confirmDeleteSession: 'Вы уверены, что хотите удалить эту запись? Действие необратимо.',
+ confirmDeleteAllTitle: 'Удалить всю историю?',
+ confirmDeleteAll: 'Вы уверены, что хотите удалить ВСЮ историю собраний? Это действие необратимо!',
+ overtimeRate: 'Процент превышения',
+ avgAttendance: 'Средняя явка',
+ recentSessions: 'Последние собрания',
+ noSessions: 'Собраний пока нет',
+ participantBreakdown: 'Статистика по участникам',
+ name: 'Имя',
+ sessions: 'Собрания',
+ overtime: 'Превышение',
+ attendance: 'Явка',
+ },
+
+ // Settings page
+ settings: {
+ title: 'Настройки собрания',
+ language: 'Язык',
+ sound: 'Звуковые уведомления',
+ soundEnabled: 'Включены',
+ soundDisabled: 'Выключены',
+ warningTime: 'Предупреждение за',
+ seconds: 'сек',
+ defaultSpeakerTime: 'Время на спикера по умолчанию',
+ defaultTotalTime: 'Общее время собрания (мин)',
+ theme: 'Тема оформления',
+ themeDark: 'Тёмная',
+ themeLight: 'Светлая',
+ save: 'Сохранить',
+ saved: 'Сохранено!',
+ windowWidth: 'Настройка окна',
+ windowWidthHint: 'Ширина окна (мин. 480 пикселей)',
+ windowFullHeight: 'Окно на всю высоту экрана',
+ },
+
+ // Participant management
+ participants: {
+ title: 'Управление участниками',
+ add: 'Добавить',
+ edit: 'Редактировать',
+ delete: 'Удалить',
+ name: 'Имя',
+ stats: 'Статистика',
+ avgSpeakTime: 'Среднее время выступления',
+ totalMeetings: 'Всего собраний',
+ confirmDelete: 'Удалить участника?',
+ },
+
+ // Common
+ common: {
+ cancel: 'Отмена',
+ confirm: 'Подтвердить',
+ save: 'Сохранить',
+ close: 'Закрыть',
+ delete: 'Удалить',
+ yes: 'Да',
+ no: 'Нет',
+ loading: 'Загрузка...',
+ error: 'Ошибка',
+ },
+
+ // Time formats
+ time: {
+ hours: 'ч',
+ minutes: 'мин',
+ seconds: 'сек',
+ },
+ },
+
+ en: {
+ // Navigation
+ nav: {
+ timer: 'Timer',
+ setup: 'Participants',
+ history: 'History',
+ settings: 'Settings',
+ },
+
+ // Setup page
+ setup: {
+ title: 'New Meeting',
+ participants: 'Participants',
+ addParticipant: 'Add Participant',
+ namePlaceholder: 'Participant name',
+ noParticipants: 'Add participants to start a meeting',
+ selectAll: 'Select All',
+ deselectAll: 'Deselect All',
+ startMeeting: 'Start Meeting',
+ speakerTime: 'Speaker Time',
+ totalTime: 'Total Time',
+ minutes: 'min',
+ unlimited: 'Unlimited',
+ dragHint: 'drag to reorder, ✓/✗ attendance',
+ },
+
+ // Timer page
+ timer: {
+ currentSpeaker: 'Current Speaker',
+ speakerTime: 'Speaker Time',
+ totalTime: 'Total Time',
+ remaining: 'Remaining',
+ queue: 'Queue',
+ participants: 'Participants',
+ finished: 'Finished',
+ noSpeaker: 'No speaker',
+ noActiveMeeting: 'No active meeting',
+ goToParticipants: 'Go to Participants to add participants',
+ readyToStart: 'Ready to start',
+ editParticipants: 'Edit participants',
+ noParticipants: 'No participants added',
+ registeredParticipants: 'Registered participants',
+ },
+
+ // Controls
+ controls: {
+ next: 'Next',
+ skip: 'Skip',
+ pause: 'Pause',
+ resume: 'Resume',
+ stop: 'Stop',
+ },
+
+ // History page
+ history: {
+ title: 'Meeting History',
+ noHistory: 'No history yet',
+ date: 'Date',
+ duration: 'Duration',
+ participants: 'Participants',
+ avgTime: 'Avg Time',
+ export: 'Export',
+ exportJSON: 'Export JSON',
+ exportCSV: 'Export CSV',
+ delete: 'Delete',
+ deleteAll: 'Delete History',
+ deleteSession: 'Delete session',
+ confirmDelete: 'Delete this record?',
+ confirmDeleteTitle: 'Confirm Deletion',
+ confirmDeleteSession: 'Are you sure you want to delete this session? This action cannot be undone.',
+ confirmDeleteAllTitle: 'Delete All History?',
+ confirmDeleteAll: 'Are you sure you want to delete ALL meeting history? This action cannot be undone!',
+ overtimeRate: 'Overtime Rate',
+ avgAttendance: 'Avg. Attendance',
+ recentSessions: 'Recent Sessions',
+ noSessions: 'No sessions yet',
+ participantBreakdown: 'Participant Breakdown',
+ name: 'Name',
+ sessions: 'Sessions',
+ overtime: 'Overtime',
+ attendance: 'Attendance',
+ },
+
+ // Settings page
+ settings: {
+ title: 'Meeting Settings',
+ language: 'Language',
+ sound: 'Sound Notifications',
+ soundEnabled: 'Enabled',
+ soundDisabled: 'Disabled',
+ warningTime: 'Warning before',
+ seconds: 'sec',
+ defaultSpeakerTime: 'Default Speaker Time',
+ defaultTotalTime: 'Total meeting time (min)',
+ theme: 'Theme',
+ themeDark: 'Dark',
+ themeLight: 'Light',
+ save: 'Save',
+ saved: 'Saved!',
+ windowWidth: 'Window Settings',
+ windowWidthHint: 'Window width (min. 480 pixels)',
+ windowFullHeight: 'Full screen height window',
+ },
+
+ // Participant management
+ participants: {
+ title: 'Manage Participants',
+ add: 'Add',
+ edit: 'Edit',
+ delete: 'Delete',
+ name: 'Name',
+ stats: 'Statistics',
+ avgSpeakTime: 'Avg Speaking Time',
+ totalMeetings: 'Total Meetings',
+ confirmDelete: 'Delete participant?',
+ },
+
+ // Common
+ common: {
+ cancel: 'Cancel',
+ confirm: 'Confirm',
+ save: 'Save',
+ close: 'Close',
+ delete: 'Delete',
+ yes: 'Yes',
+ no: 'No',
+ loading: 'Loading...',
+ error: 'Error',
+ },
+
+ // Time formats
+ time: {
+ hours: 'h',
+ minutes: 'min',
+ seconds: 'sec',
+ },
+ },
+};
+
+export const t = derived(locale, ($locale) => {
+ return (key) => {
+ const keys = key.split('.');
+ let value = translations[$locale];
+
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in value) {
+ value = value[k];
+ } else {
+ console.warn(`Translation missing: ${key} for locale ${$locale}`);
+ return key;
+ }
+ }
+
+ return value;
+ };
+});
+
+export function setLocale(lang) {
+ if (translations[lang]) {
+ locale.set(lang);
+ localStorage.setItem('daily-timer-locale', lang);
+ }
+}
+
+export function initLocale() {
+ const saved = localStorage.getItem('daily-timer-locale');
+ if (saved && translations[saved]) {
+ locale.set(saved);
+ } else {
+ const browserLang = navigator.language.split('-')[0];
+ if (translations[browserLang]) {
+ locale.set(browserLang);
+ }
+ }
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..7f13bc6
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,7 @@
+import App from './App.svelte';
+
+const app = new App({
+ target: document.getElementById('app'),
+});
+
+export default app;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..66ec259
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+
+export default defineConfig({
+ plugins: [svelte()],
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+});
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
new file mode 100755
index 0000000..633c7a5
--- /dev/null
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -0,0 +1,53 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+import {models} from '../models';
+
+export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise;
+
+export function DeleteAllSessions():Promise;
+
+export function DeleteParticipant(arg1:number):Promise;
+
+export function DeleteSession(arg1:number):Promise;
+
+export function ExportCSV(arg1:string,arg2:string):Promise;
+
+export function ExportData(arg1:string,arg2:string):Promise;
+
+export function GetMeeting():Promise;
+
+export function GetParticipants():Promise>;
+
+export function GetSession(arg1:number):Promise;
+
+export function GetSessions(arg1:number,arg2:number):Promise>;
+
+export function GetSettings():Promise;
+
+export function GetSoundsDir():Promise;
+
+export function GetStatistics(arg1:string,arg2:string):Promise;
+
+export function GetTimerState():Promise;
+
+export function NextSpeaker():Promise;
+
+export function PauseMeeting():Promise;
+
+export function RemoveFromQueue(arg1:number):Promise;
+
+export function ReorderParticipants(arg1:Array):Promise;
+
+export function ResumeMeeting():Promise;
+
+export function SkipSpeaker():Promise;
+
+export function StartMeeting(arg1:Array,arg2:Record):Promise;
+
+export function StopMeeting():Promise;
+
+export function UpdateMeeting(arg1:string,arg2:number):Promise;
+
+export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise;
+
+export function UpdateSettings(arg1:models.Settings):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
new file mode 100755
index 0000000..fd86be6
--- /dev/null
+++ b/frontend/wailsjs/go/app/App.js
@@ -0,0 +1,103 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function AddParticipant(arg1, arg2, arg3) {
+ return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3);
+}
+
+export function DeleteAllSessions() {
+ return window['go']['app']['App']['DeleteAllSessions']();
+}
+
+export function DeleteParticipant(arg1) {
+ return window['go']['app']['App']['DeleteParticipant'](arg1);
+}
+
+export function DeleteSession(arg1) {
+ return window['go']['app']['App']['DeleteSession'](arg1);
+}
+
+export function ExportCSV(arg1, arg2) {
+ return window['go']['app']['App']['ExportCSV'](arg1, arg2);
+}
+
+export function ExportData(arg1, arg2) {
+ return window['go']['app']['App']['ExportData'](arg1, arg2);
+}
+
+export function GetMeeting() {
+ return window['go']['app']['App']['GetMeeting']();
+}
+
+export function GetParticipants() {
+ return window['go']['app']['App']['GetParticipants']();
+}
+
+export function GetSession(arg1) {
+ return window['go']['app']['App']['GetSession'](arg1);
+}
+
+export function GetSessions(arg1, arg2) {
+ return window['go']['app']['App']['GetSessions'](arg1, arg2);
+}
+
+export function GetSettings() {
+ return window['go']['app']['App']['GetSettings']();
+}
+
+export function GetSoundsDir() {
+ return window['go']['app']['App']['GetSoundsDir']();
+}
+
+export function GetStatistics(arg1, arg2) {
+ return window['go']['app']['App']['GetStatistics'](arg1, arg2);
+}
+
+export function GetTimerState() {
+ return window['go']['app']['App']['GetTimerState']();
+}
+
+export function NextSpeaker() {
+ return window['go']['app']['App']['NextSpeaker']();
+}
+
+export function PauseMeeting() {
+ return window['go']['app']['App']['PauseMeeting']();
+}
+
+export function RemoveFromQueue(arg1) {
+ return window['go']['app']['App']['RemoveFromQueue'](arg1);
+}
+
+export function ReorderParticipants(arg1) {
+ return window['go']['app']['App']['ReorderParticipants'](arg1);
+}
+
+export function ResumeMeeting() {
+ return window['go']['app']['App']['ResumeMeeting']();
+}
+
+export function SkipSpeaker() {
+ return window['go']['app']['App']['SkipSpeaker']();
+}
+
+export function StartMeeting(arg1, arg2) {
+ return window['go']['app']['App']['StartMeeting'](arg1, arg2);
+}
+
+export function StopMeeting() {
+ return window['go']['app']['App']['StopMeeting']();
+}
+
+export function UpdateMeeting(arg1, arg2) {
+ return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
+}
+
+export function UpdateParticipant(arg1, arg2, arg3, arg4) {
+ return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4);
+}
+
+export function UpdateSettings(arg1) {
+ return window['go']['app']['App']['UpdateSettings'](arg1);
+}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
new file mode 100755
index 0000000..c27b4e3
--- /dev/null
+++ b/frontend/wailsjs/go/models.ts
@@ -0,0 +1,434 @@
+export namespace models {
+
+ export class ParticipantBreakdown {
+ participantId: number;
+ name: string;
+ sessionsAttended: number;
+ totalSpeakingTime: number;
+ averageSpeakingTime: number;
+ overtimeCount: number;
+ skipCount: number;
+ attendanceRate: number;
+
+ static createFrom(source: any = {}) {
+ return new ParticipantBreakdown(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.participantId = source["participantId"];
+ this.name = source["name"];
+ this.sessionsAttended = source["sessionsAttended"];
+ this.totalSpeakingTime = source["totalSpeakingTime"];
+ this.averageSpeakingTime = source["averageSpeakingTime"];
+ this.overtimeCount = source["overtimeCount"];
+ this.skipCount = source["skipCount"];
+ this.attendanceRate = source["attendanceRate"];
+ }
+ }
+ export class AggregatedStats {
+ totalSessions: number;
+ totalMeetingTime: number;
+ averageMeetingTime: number;
+ overtimeSessions: number;
+ overtimePercentage: number;
+ averageAttendance: number;
+ participantBreakdown: ParticipantBreakdown[];
+
+ static createFrom(source: any = {}) {
+ return new AggregatedStats(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.totalSessions = source["totalSessions"];
+ this.totalMeetingTime = source["totalMeetingTime"];
+ this.averageMeetingTime = source["averageMeetingTime"];
+ this.overtimeSessions = source["overtimeSessions"];
+ this.overtimePercentage = source["overtimePercentage"];
+ this.averageAttendance = source["averageAttendance"];
+ this.participantBreakdown = this.convertValues(source["participantBreakdown"], ParticipantBreakdown);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class SessionAttendance {
+ id: number;
+ sessionId: number;
+ participantId: number;
+ participant?: Participant;
+ present: boolean;
+ joinedLate: boolean;
+
+ static createFrom(source: any = {}) {
+ return new SessionAttendance(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.sessionId = source["sessionId"];
+ this.participantId = source["participantId"];
+ this.participant = this.convertValues(source["participant"], Participant);
+ this.present = source["present"];
+ this.joinedLate = source["joinedLate"];
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class Participant {
+ id: number;
+ name: string;
+ email?: string;
+ timeLimit: number;
+ order: number;
+ active: boolean;
+ // Go type: time
+ createdAt: any;
+ // Go type: time
+ updatedAt: any;
+
+ static createFrom(source: any = {}) {
+ return new Participant(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.email = source["email"];
+ this.timeLimit = source["timeLimit"];
+ this.order = source["order"];
+ this.active = source["active"];
+ this.createdAt = this.convertValues(source["createdAt"], null);
+ this.updatedAt = this.convertValues(source["updatedAt"], null);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class ParticipantLog {
+ id: number;
+ sessionId: number;
+ participantId: number;
+ participant?: Participant;
+ // Go type: time
+ startedAt: any;
+ // Go type: time
+ endedAt?: any;
+ duration: number;
+ skipped: boolean;
+ overtime: boolean;
+ order: number;
+
+ static createFrom(source: any = {}) {
+ return new ParticipantLog(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.sessionId = source["sessionId"];
+ this.participantId = source["participantId"];
+ this.participant = this.convertValues(source["participant"], Participant);
+ this.startedAt = this.convertValues(source["startedAt"], null);
+ this.endedAt = this.convertValues(source["endedAt"], null);
+ this.duration = source["duration"];
+ this.skipped = source["skipped"];
+ this.overtime = source["overtime"];
+ this.order = source["order"];
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class MeetingSession {
+ id: number;
+ meetingId: number;
+ // Go type: time
+ startedAt: any;
+ // Go type: time
+ endedAt?: any;
+ totalDuration: number;
+ completed: boolean;
+ participantLogs?: ParticipantLog[];
+ attendance?: SessionAttendance[];
+
+ static createFrom(source: any = {}) {
+ return new MeetingSession(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.meetingId = source["meetingId"];
+ this.startedAt = this.convertValues(source["startedAt"], null);
+ this.endedAt = this.convertValues(source["endedAt"], null);
+ this.totalDuration = source["totalDuration"];
+ this.completed = source["completed"];
+ this.participantLogs = this.convertValues(source["participantLogs"], ParticipantLog);
+ this.attendance = this.convertValues(source["attendance"], SessionAttendance);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class Meeting {
+ id: number;
+ name: string;
+ timeLimit: number;
+ sessions?: MeetingSession[];
+ // Go type: time
+ createdAt: any;
+ // Go type: time
+ updatedAt: any;
+
+ static createFrom(source: any = {}) {
+ return new Meeting(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.timeLimit = source["timeLimit"];
+ this.sessions = this.convertValues(source["sessions"], MeetingSession);
+ this.createdAt = this.convertValues(source["createdAt"], null);
+ this.updatedAt = this.convertValues(source["updatedAt"], null);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+
+
+
+ export class QueuedSpeaker {
+ id: number;
+ name: string;
+ timeLimit: number;
+ order: number;
+
+ static createFrom(source: any = {}) {
+ return new QueuedSpeaker(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.timeLimit = source["timeLimit"];
+ this.order = source["order"];
+ }
+ }
+
+ export class Settings {
+ id: number;
+ defaultParticipantTime: number;
+ defaultMeetingTime: number;
+ soundEnabled: boolean;
+ soundWarning: string;
+ soundTimeUp: string;
+ soundMeetingEnd: string;
+ warningThreshold: number;
+ theme: string;
+ windowWidth: number;
+ windowFullHeight: boolean;
+
+ static createFrom(source: any = {}) {
+ return new Settings(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.defaultParticipantTime = source["defaultParticipantTime"];
+ this.defaultMeetingTime = source["defaultMeetingTime"];
+ this.soundEnabled = source["soundEnabled"];
+ this.soundWarning = source["soundWarning"];
+ this.soundTimeUp = source["soundTimeUp"];
+ this.soundMeetingEnd = source["soundMeetingEnd"];
+ this.warningThreshold = source["warningThreshold"];
+ this.theme = source["theme"];
+ this.windowWidth = source["windowWidth"];
+ this.windowFullHeight = source["windowFullHeight"];
+ }
+ }
+ export class SpeakerInfo {
+ id: number;
+ name: string;
+ timeLimit: number;
+ order: number;
+ status: string;
+
+ static createFrom(source: any = {}) {
+ return new SpeakerInfo(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.timeLimit = source["timeLimit"];
+ this.order = source["order"];
+ this.status = source["status"];
+ }
+ }
+ export class TimerState {
+ running: boolean;
+ paused: boolean;
+ currentSpeakerId: number;
+ currentSpeaker: string;
+ speakerElapsed: number;
+ speakerLimit: number;
+ meetingElapsed: number;
+ meetingLimit: number;
+ speakerOvertime: boolean;
+ meetingOvertime: boolean;
+ warning: boolean;
+ warningSeconds: number;
+ totalSpeakersTime: number;
+ speakingOrder: number;
+ totalSpeakers: number;
+ remainingQueue: QueuedSpeaker[];
+ allSpeakers: SpeakerInfo[];
+
+ static createFrom(source: any = {}) {
+ return new TimerState(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.running = source["running"];
+ this.paused = source["paused"];
+ this.currentSpeakerId = source["currentSpeakerId"];
+ this.currentSpeaker = source["currentSpeaker"];
+ this.speakerElapsed = source["speakerElapsed"];
+ this.speakerLimit = source["speakerLimit"];
+ this.meetingElapsed = source["meetingElapsed"];
+ this.meetingLimit = source["meetingLimit"];
+ this.speakerOvertime = source["speakerOvertime"];
+ this.meetingOvertime = source["meetingOvertime"];
+ this.warning = source["warning"];
+ this.warningSeconds = source["warningSeconds"];
+ this.totalSpeakersTime = source["totalSpeakersTime"];
+ this.speakingOrder = source["speakingOrder"];
+ this.totalSpeakers = source["totalSpeakers"];
+ this.remainingQueue = this.convertValues(source["remainingQueue"], QueuedSpeaker);
+ this.allSpeakers = this.convertValues(source["allSpeakers"], SpeakerInfo);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+}
+
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
new file mode 100644
index 0000000..1e7c8a5
--- /dev/null
+++ b/frontend/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 0000000..4445dac
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,249 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
\ No newline at end of file
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 0000000..7cb89d7
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.js
@@ -0,0 +1,242 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOffAll() {
+ return window.runtime.EventsOffAll();
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6c9498c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,42 @@
+module daily-timer
+
+go 1.23
+
+require (
+ github.com/wailsapp/wails/v2 v2.11.0
+ gorm.io/driver/sqlite v1.5.6
+ gorm.io/gorm v1.25.12
+)
+
+require (
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7d33aba
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,91 @@
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
+github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
+gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..3fda8ef
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,481 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "daily-timer/internal/models"
+ "daily-timer/internal/storage"
+ "daily-timer/internal/timer"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+type App struct {
+ ctx context.Context
+ store *storage.Storage
+ timer *timer.Timer
+ session *models.MeetingSession
+ currentLogs map[uint]*models.ParticipantLog
+}
+
+func New(store *storage.Storage) *App {
+ return &App{
+ store: store,
+ currentLogs: make(map[uint]*models.ParticipantLog),
+ }
+}
+
+func (a *App) Startup(ctx context.Context) {
+ a.ctx = ctx
+}
+
+func (a *App) OnDomReady(ctx context.Context) {
+ runtime.WindowShow(ctx)
+}
+
+func (a *App) Shutdown(ctx context.Context) {
+ if a.timer != nil {
+ a.timer.Close()
+ }
+ if a.store != nil {
+ _ = a.store.Close()
+ }
+}
+
+// Participants
+
+func (a *App) GetParticipants() ([]models.Participant, error) {
+ return a.store.GetParticipants()
+}
+
+func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.Participant, error) {
+ participants, _ := a.store.GetAllParticipants()
+ order := len(participants)
+
+ p := &models.Participant{
+ Name: name,
+ Email: email,
+ TimeLimit: timeLimit,
+ Order: order,
+ Active: true,
+ }
+
+ if err := a.store.CreateParticipant(p); err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int) error {
+ p := &models.Participant{
+ ID: id,
+ Name: name,
+ Email: email,
+ TimeLimit: timeLimit,
+ }
+ return a.store.UpdateParticipant(p)
+}
+
+func (a *App) DeleteParticipant(id uint) error {
+ return a.store.DeleteParticipant(id)
+}
+
+func (a *App) ReorderParticipants(ids []uint) error {
+ return a.store.ReorderParticipants(ids)
+}
+
+// Meeting
+
+func (a *App) GetMeeting() (*models.Meeting, error) {
+ return a.store.GetMeeting()
+}
+
+func (a *App) UpdateMeeting(name string, timeLimit int) error {
+ meeting, err := a.store.GetMeeting()
+ if err != nil {
+ return err
+ }
+ meeting.Name = name
+ meeting.TimeLimit = timeLimit
+ return a.store.UpdateMeeting(meeting)
+}
+
+// Timer Controls
+
+func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) error {
+ meeting, err := a.store.GetMeeting()
+ if err != nil {
+ return err
+ }
+
+ settings, _ := a.store.GetSettings()
+ warningThreshold := 30
+ if settings != nil {
+ warningThreshold = settings.WarningThreshold
+ }
+
+ session, err := a.store.CreateSession(meeting.ID)
+ if err != nil {
+ return err
+ }
+ a.session = session
+
+ for participantID, present := range attendance {
+ _ = a.store.SetAttendance(session.ID, participantID, present, false)
+ }
+
+ participants, _ := a.store.GetParticipants()
+ participantMap := make(map[uint]models.Participant)
+ for _, p := range participants {
+ participantMap[p.ID] = p
+ }
+
+ queue := make([]models.QueuedSpeaker, 0, len(participantOrder))
+ for i, id := range participantOrder {
+ if p, ok := participantMap[id]; ok {
+ if present, ok := attendance[id]; ok && present {
+ queue = append(queue, models.QueuedSpeaker{
+ ID: p.ID,
+ Name: p.Name,
+ TimeLimit: p.TimeLimit,
+ Order: i,
+ })
+ }
+ }
+ }
+
+ a.timer = timer.New(meeting.TimeLimit, warningThreshold)
+ a.timer.SetQueue(queue)
+ a.currentLogs = make(map[uint]*models.ParticipantLog)
+ go a.handleTimerEvents()
+ a.timer.Start()
+
+ return nil
+}
+
+func (a *App) handleTimerEvents() {
+ for event := range a.timer.Events() {
+ switch event.Type {
+ case timer.EventTick:
+ runtime.EventsEmit(a.ctx, "timer:tick", event.State)
+ case timer.EventSpeakerWarning:
+ runtime.EventsEmit(a.ctx, "timer:speaker_warning", event.State)
+ case timer.EventSpeakerTimeUp:
+ runtime.EventsEmit(a.ctx, "timer:speaker_timeup", event.State)
+ case timer.EventMeetingWarning:
+ runtime.EventsEmit(a.ctx, "timer:meeting_warning", event.State)
+ case timer.EventMeetingTimeUp:
+ runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
+ case timer.EventSpeakerChanged:
+ a.saveSpeakerLog(event.State)
+ runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
+ case timer.EventMeetingEnded:
+ a.saveSpeakerLog(event.State)
+ a.endMeetingSession(event.State)
+ runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
+ }
+ }
+}
+
+func (a *App) saveSpeakerLog(state models.TimerState) {
+ if a.session == nil {
+ return
+ }
+
+ for id, log := range a.currentLogs {
+ if log.EndedAt == nil {
+ now := time.Now()
+ log.EndedAt = &now
+ log.Duration = int(now.Sub(log.StartedAt).Seconds())
+
+ participants, _ := a.store.GetParticipants()
+ for _, p := range participants {
+ if p.ID == id {
+ log.Overtime = log.Duration > p.TimeLimit
+ break
+ }
+ }
+ _ = a.store.UpdateParticipantLog(log)
+ }
+ }
+
+ if state.CurrentSpeakerID > 0 {
+ log := &models.ParticipantLog{
+ SessionID: a.session.ID,
+ ParticipantID: state.CurrentSpeakerID,
+ StartedAt: time.Now(),
+ Order: state.SpeakingOrder,
+ }
+ _ = a.store.CreateParticipantLog(log)
+ a.currentLogs[state.CurrentSpeakerID] = log
+ }
+}
+
+func (a *App) endMeetingSession(state models.TimerState) {
+ if a.session == nil {
+ return
+ }
+ _ = a.store.EndSession(a.session.ID, state.MeetingElapsed)
+ a.session = nil
+ a.currentLogs = make(map[uint]*models.ParticipantLog)
+}
+
+func (a *App) NextSpeaker() {
+ if a.timer != nil {
+ a.timer.NextSpeaker()
+ }
+}
+
+func (a *App) SkipSpeaker() {
+ if a.timer != nil {
+ a.timer.SkipSpeaker()
+ }
+}
+
+func (a *App) RemoveFromQueue(speakerID uint) {
+ if a.timer != nil {
+ a.timer.RemoveFromQueue(speakerID)
+ }
+}
+
+func (a *App) PauseMeeting() {
+ if a.timer != nil {
+ a.timer.Pause()
+ }
+}
+
+func (a *App) ResumeMeeting() {
+ if a.timer != nil {
+ a.timer.Resume()
+ }
+}
+
+func (a *App) StopMeeting() {
+ if a.timer != nil {
+ a.timer.Stop()
+ }
+}
+
+func (a *App) GetTimerState() *models.TimerState {
+ if a.timer == nil {
+ return nil
+ }
+ state := a.timer.GetState()
+ return &state
+}
+
+// Settings
+
+func (a *App) GetSettings() (*models.Settings, error) {
+ return a.store.GetSettings()
+}
+
+func (a *App) UpdateSettings(settings *models.Settings) error {
+ return a.store.UpdateSettings(settings)
+}
+
+// History & Statistics
+
+func (a *App) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
+ return a.store.GetSessions(limit, offset)
+}
+
+func (a *App) GetSession(id uint) (*models.MeetingSession, error) {
+ return a.store.GetSession(id)
+}
+
+func (a *App) DeleteSession(id uint) error {
+ return a.store.DeleteSession(id)
+}
+
+func (a *App) DeleteAllSessions() error {
+ return a.store.DeleteAllSessions()
+}
+
+func (a *App) GetStatistics(fromStr, toStr string) (*models.AggregatedStats, error) {
+ from, err := time.Parse("2006-01-02", fromStr)
+ if err != nil {
+ from = time.Now().AddDate(0, -1, 0)
+ }
+
+ to, err := time.Parse("2006-01-02", toStr)
+ if err != nil {
+ to = time.Now()
+ }
+
+ return a.store.GetAggregatedStats(from, to)
+}
+
+func (a *App) ExportData(fromStr, toStr string) (string, error) {
+ from, _ := time.Parse("2006-01-02", fromStr)
+ to, _ := time.Parse("2006-01-02", toStr)
+
+ if from.IsZero() {
+ from = time.Now().AddDate(-1, 0, 0)
+ }
+ if to.IsZero() {
+ to = time.Now()
+ }
+
+ participants, err := a.store.GetAllParticipants()
+ if err != nil {
+ return "", err
+ }
+
+ sessions, err := a.store.GetSessions(1000, 0)
+ if err != nil {
+ return "", err
+ }
+
+ meeting, _ := a.store.GetMeeting()
+ sessionStats := make([]models.SessionStats, 0, len(sessions))
+
+ for _, s := range sessions {
+ if s.StartedAt.Before(from) || s.StartedAt.After(to) {
+ continue
+ }
+
+ stats := models.SessionStats{
+ SessionID: s.ID,
+ Date: s.StartedAt.Format("2006-01-02 15:04"),
+ TotalDuration: s.TotalDuration,
+ MeetingLimit: meeting.TimeLimit,
+ Overtime: s.TotalDuration > meeting.TimeLimit,
+ }
+
+ for _, log := range s.ParticipantLogs {
+ stats.ParticipantStats = append(stats.ParticipantStats, models.ParticipantStats{
+ ParticipantID: log.ParticipantID,
+ Name: log.Participant.Name,
+ Duration: log.Duration,
+ TimeLimit: log.Participant.TimeLimit,
+ Overtime: log.Overtime,
+ Skipped: log.Skipped,
+ SpeakingOrder: log.Order,
+ })
+ if log.Overtime {
+ stats.OvertimeCount++
+ }
+ if log.Skipped {
+ stats.SkippedCount++
+ }
+ }
+
+ for _, att := range s.Attendance {
+ if att.Present {
+ stats.PresentCount++
+ } else {
+ stats.AbsentCount++
+ }
+ }
+ stats.ParticipantCount = stats.PresentCount + stats.AbsentCount
+
+ sessionStats = append(sessionStats, stats)
+ }
+
+ aggStats, _ := a.store.GetAggregatedStats(from, to)
+
+ exportData := models.ExportData{
+ ExportedAt: time.Now().Format(time.RFC3339),
+ Participants: participants,
+ Sessions: sessionStats,
+ Statistics: *aggStats,
+ }
+
+ data, err := json.MarshalIndent(exportData, "", " ")
+ if err != nil {
+ return "", err
+ }
+
+ filename := fmt.Sprintf("daily-timer-export-%s.json", time.Now().Format("2006-01-02"))
+ savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+ DefaultFilename: filename,
+ Filters: []runtime.FileFilter{
+ {DisplayName: "JSON Files", Pattern: "*.json"},
+ },
+ })
+ if err != nil {
+ return "", err
+ }
+ if savePath == "" {
+ return "", nil
+ }
+
+ if err := os.WriteFile(savePath, data, 0644); err != nil {
+ return "", err
+ }
+
+ return savePath, nil
+}
+
+func (a *App) ExportCSV(fromStr, toStr string) (string, error) {
+ from, _ := time.Parse("2006-01-02", fromStr)
+ to, _ := time.Parse("2006-01-02", toStr)
+
+ if from.IsZero() {
+ from = time.Now().AddDate(-1, 0, 0)
+ }
+ if to.IsZero() {
+ to = time.Now()
+ }
+
+ sessions, err := a.store.GetSessions(1000, 0)
+ if err != nil {
+ return "", err
+ }
+
+ csv := "Date,Participant,Duration (s),Time Limit (s),Overtime,Skipped,Speaking Order\n"
+
+ for _, s := range sessions {
+ if s.StartedAt.Before(from) || s.StartedAt.After(to) {
+ continue
+ }
+ date := s.StartedAt.Format("2006-01-02")
+
+ for _, log := range s.ParticipantLogs {
+ csv += fmt.Sprintf("%s,%s,%d,%d,%t,%t,%d\n",
+ date,
+ log.Participant.Name,
+ log.Duration,
+ log.Participant.TimeLimit,
+ log.Overtime,
+ log.Skipped,
+ log.Order,
+ )
+ }
+ }
+
+ filename := fmt.Sprintf("daily-timer-export-%s.csv", time.Now().Format("2006-01-02"))
+ savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+ DefaultFilename: filename,
+ Filters: []runtime.FileFilter{
+ {DisplayName: "CSV Files", Pattern: "*.csv"},
+ },
+ })
+ if err != nil {
+ return "", err
+ }
+ if savePath == "" {
+ return "", nil
+ }
+
+ if err := os.WriteFile(savePath, []byte(csv), 0644); err != nil {
+ return "", err
+ }
+
+ return savePath, nil
+}
+
+// Sound
+
+func (a *App) GetSoundsDir() string {
+ configDir, _ := os.UserConfigDir()
+ soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
+ _ = os.MkdirAll(soundsDir, 0755)
+ return soundsDir
+}
diff --git a/internal/models/models.go b/internal/models/models.go
new file mode 100644
index 0000000..2dcf5c3
--- /dev/null
+++ b/internal/models/models.go
@@ -0,0 +1,72 @@
+package models
+
+import (
+ "time"
+)
+
+type Participant struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ Name string `json:"name" gorm:"not null"`
+ Email string `json:"email,omitempty"`
+ TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
+ Order int `json:"order" gorm:"default:0"`
+ Active bool `json:"active" gorm:"default:true"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type Meeting struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ Name string `json:"name" gorm:"not null;default:Daily Standup"`
+ TimeLimit int `json:"timeLimit" gorm:"default:3600"` // total meeting limit in seconds (1 hour)
+ Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type MeetingSession struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ MeetingID uint `json:"meetingId" gorm:"not null"`
+ StartedAt time.Time `json:"startedAt"`
+ EndedAt *time.Time `json:"endedAt,omitempty"`
+ TotalDuration int `json:"totalDuration"` // seconds
+ Completed bool `json:"completed" gorm:"default:false"`
+ ParticipantLogs []ParticipantLog `json:"participantLogs,omitempty" gorm:"foreignKey:SessionID"`
+ Attendance []SessionAttendance `json:"attendance,omitempty" gorm:"foreignKey:SessionID"`
+}
+
+type ParticipantLog struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ SessionID uint `json:"sessionId" gorm:"not null"`
+ ParticipantID uint `json:"participantId" gorm:"not null"`
+ Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
+ StartedAt time.Time `json:"startedAt"`
+ EndedAt *time.Time `json:"endedAt,omitempty"`
+ Duration int `json:"duration"` // seconds
+ Skipped bool `json:"skipped" gorm:"default:false"`
+ Overtime bool `json:"overtime" gorm:"default:false"`
+ Order int `json:"order"` // speaking order in session
+}
+
+type SessionAttendance struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ SessionID uint `json:"sessionId" gorm:"not null"`
+ ParticipantID uint `json:"participantId" gorm:"not null"`
+ Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
+ Present bool `json:"present" gorm:"default:true"`
+ JoinedLate bool `json:"joinedLate" gorm:"default:false"`
+}
+
+type Settings struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ DefaultParticipantTime int `json:"defaultParticipantTime" gorm:"default:120"` // seconds
+ DefaultMeetingTime int `json:"defaultMeetingTime" gorm:"default:900"` // seconds
+ SoundEnabled bool `json:"soundEnabled" gorm:"default:true"`
+ SoundWarning string `json:"soundWarning" gorm:"default:warning.mp3"`
+ SoundTimeUp string `json:"soundTimeUp" gorm:"default:timeup.mp3"`
+ SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
+ WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
+ Theme string `json:"theme" gorm:"default:dark"`
+ WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
+ WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
+}
diff --git a/internal/models/types.go b/internal/models/types.go
new file mode 100644
index 0000000..02c346f
--- /dev/null
+++ b/internal/models/types.go
@@ -0,0 +1,98 @@
+package models
+
+type SpeakerStatus string
+
+const (
+ SpeakerStatusPending SpeakerStatus = "pending"
+ SpeakerStatusSpeaking SpeakerStatus = "speaking"
+ SpeakerStatusDone SpeakerStatus = "done"
+ SpeakerStatusSkipped SpeakerStatus = "skipped"
+)
+
+type TimerState struct {
+ Running bool `json:"running"`
+ Paused bool `json:"paused"`
+ CurrentSpeakerID uint `json:"currentSpeakerId"`
+ CurrentSpeaker string `json:"currentSpeaker"`
+ SpeakerElapsed int `json:"speakerElapsed"` // seconds
+ SpeakerLimit int `json:"speakerLimit"` // seconds
+ MeetingElapsed int `json:"meetingElapsed"` // seconds
+ MeetingLimit int `json:"meetingLimit"` // seconds
+ SpeakerOvertime bool `json:"speakerOvertime"`
+ MeetingOvertime bool `json:"meetingOvertime"`
+ Warning bool `json:"warning"`
+ WarningSeconds int `json:"warningSeconds"` // seconds before end for warning
+ TotalSpeakersTime int `json:"totalSpeakersTime"` // sum of all speaker limits
+ SpeakingOrder int `json:"speakingOrder"`
+ TotalSpeakers int `json:"totalSpeakers"`
+ RemainingQueue []QueuedSpeaker `json:"remainingQueue"`
+ AllSpeakers []SpeakerInfo `json:"allSpeakers"`
+}
+
+type SpeakerInfo struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ TimeLimit int `json:"timeLimit"`
+ Order int `json:"order"`
+ Status SpeakerStatus `json:"status"`
+}
+
+type QueuedSpeaker struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ TimeLimit int `json:"timeLimit"`
+ Order int `json:"order"`
+}
+
+type SessionStats struct {
+ SessionID uint `json:"sessionId"`
+ Date string `json:"date"`
+ TotalDuration int `json:"totalDuration"`
+ MeetingLimit int `json:"meetingLimit"`
+ Overtime bool `json:"overtime"`
+ ParticipantCount int `json:"participantCount"`
+ PresentCount int `json:"presentCount"`
+ AbsentCount int `json:"absentCount"`
+ OvertimeCount int `json:"overtimeCount"`
+ SkippedCount int `json:"skippedCount"`
+ ParticipantStats []ParticipantStats `json:"participantStats"`
+}
+
+type ParticipantStats struct {
+ ParticipantID uint `json:"participantId"`
+ Name string `json:"name"`
+ Duration int `json:"duration"`
+ TimeLimit int `json:"timeLimit"`
+ Overtime bool `json:"overtime"`
+ Skipped bool `json:"skipped"`
+ Present bool `json:"present"`
+ SpeakingOrder int `json:"speakingOrder"`
+}
+
+type AggregatedStats struct {
+ TotalSessions int `json:"totalSessions"`
+ TotalMeetingTime int `json:"totalMeetingTime"`
+ AverageMeetingTime float64 `json:"averageMeetingTime"`
+ OvertimeSessions int `json:"overtimeSessions"`
+ OvertimePercentage float64 `json:"overtimePercentage"`
+ AverageAttendance float64 `json:"averageAttendance"`
+ ParticipantBreakdown []ParticipantBreakdown `json:"participantBreakdown"`
+}
+
+type ParticipantBreakdown struct {
+ ParticipantID uint `json:"participantId"`
+ Name string `json:"name"`
+ SessionsAttended int `json:"sessionsAttended"`
+ TotalSpeakingTime int `json:"totalSpeakingTime"`
+ AverageSpeakingTime float64 `json:"averageSpeakingTime"`
+ OvertimeCount int `json:"overtimeCount"`
+ SkipCount int `json:"skipCount"`
+ AttendanceRate float64 `json:"attendanceRate"`
+}
+
+type ExportData struct {
+ ExportedAt string `json:"exportedAt"`
+ Participants []Participant `json:"participants"`
+ Sessions []SessionStats `json:"sessions"`
+ Statistics AggregatedStats `json:"statistics"`
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..4e1b164
--- /dev/null
+++ b/internal/storage/storage.go
@@ -0,0 +1,331 @@
+package storage
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "daily-timer/internal/models"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+type Storage struct {
+ db *gorm.DB
+}
+
+func New() (*Storage, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ configDir = "."
+ }
+
+ appDir := filepath.Join(configDir, "DailyTimer")
+ if err := os.MkdirAll(appDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ dbPath := filepath.Join(appDir, "daily-timer.db")
+
+ db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ if err := db.AutoMigrate(
+ &models.Participant{},
+ &models.Meeting{},
+ &models.MeetingSession{},
+ &models.ParticipantLog{},
+ &models.SessionAttendance{},
+ &models.Settings{},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to migrate database: %w", err)
+ }
+
+ s := &Storage{db: db}
+ if err := s.ensureDefaults(); err != nil {
+ return nil, err
+ }
+
+ return s, nil
+}
+
+func (s *Storage) ensureDefaults() error {
+ var settings models.Settings
+ result := s.db.First(&settings)
+ if result.Error == gorm.ErrRecordNotFound {
+ settings = models.Settings{
+ DefaultParticipantTime: 120,
+ DefaultMeetingTime: 3600,
+ SoundEnabled: true,
+ SoundWarning: "warning",
+ SoundTimeUp: "timeup",
+ SoundMeetingEnd: "meeting_end",
+ WarningThreshold: 30,
+ Theme: "dark",
+ WindowWidth: 800,
+ WindowFullHeight: true,
+ }
+ if err := s.db.Create(&settings).Error; err != nil {
+ return fmt.Errorf("failed to create default settings: %w", err)
+ }
+ } else if settings.DefaultMeetingTime == 900 {
+ // Migrate old default value to new default (60 min instead of 15)
+ s.db.Model(&settings).Update("default_meeting_time", 3600)
+ }
+
+ var meeting models.Meeting
+ result = s.db.First(&meeting)
+ if result.Error == gorm.ErrRecordNotFound {
+ meeting = models.Meeting{
+ Name: "Daily Standup",
+ TimeLimit: 3600,
+ }
+ if err := s.db.Create(&meeting).Error; err != nil {
+ return fmt.Errorf("failed to create default meeting: %w", err)
+ }
+ } else if meeting.TimeLimit == 900 {
+ // Migrate old default value to new default (60 min instead of 15)
+ s.db.Model(&meeting).Update("time_limit", 3600)
+ }
+
+ return nil
+}
+
+// Participants
+func (s *Storage) GetParticipants() ([]models.Participant, error) {
+ var participants []models.Participant
+ err := s.db.Where("active = ?", true).Order("\"order\" ASC").Find(&participants).Error
+ return participants, err
+}
+
+func (s *Storage) GetAllParticipants() ([]models.Participant, error) {
+ var participants []models.Participant
+ err := s.db.Order("\"order\" ASC").Find(&participants).Error
+ return participants, err
+}
+
+func (s *Storage) CreateParticipant(p *models.Participant) error {
+ return s.db.Create(p).Error
+}
+
+func (s *Storage) UpdateParticipant(p *models.Participant) error {
+ return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
+ "name": p.Name,
+ "email": p.Email,
+ "time_limit": p.TimeLimit,
+ }).Error
+}
+
+func (s *Storage) DeleteParticipant(id uint) error {
+ return s.db.Model(&models.Participant{}).Where("id = ?", id).Update("active", false).Error
+}
+
+func (s *Storage) ReorderParticipants(ids []uint) error {
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ for i, id := range ids {
+ if err := tx.Model(&models.Participant{}).Where("id = ?", id).Update("\"order\"", i).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// Meetings
+func (s *Storage) GetMeeting() (*models.Meeting, error) {
+ var meeting models.Meeting
+ err := s.db.First(&meeting).Error
+ return &meeting, err
+}
+
+func (s *Storage) UpdateMeeting(m *models.Meeting) error {
+ return s.db.Save(m).Error
+}
+
+// Sessions
+func (s *Storage) CreateSession(meetingID uint) (*models.MeetingSession, error) {
+ session := &models.MeetingSession{
+ MeetingID: meetingID,
+ StartedAt: time.Now(),
+ }
+ if err := s.db.Create(session).Error; err != nil {
+ return nil, err
+ }
+ return session, nil
+}
+
+func (s *Storage) EndSession(sessionID uint, totalDuration int) error {
+ now := time.Now()
+ return s.db.Model(&models.MeetingSession{}).Where("id = ?", sessionID).Updates(map[string]interface{}{
+ "ended_at": now,
+ "total_duration": totalDuration,
+ "completed": true,
+ }).Error
+}
+
+func (s *Storage) GetSession(id uint) (*models.MeetingSession, error) {
+ var session models.MeetingSession
+ err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
+ Preload("Attendance").Preload("Attendance.Participant").
+ First(&session, id).Error
+ return &session, err
+}
+
+func (s *Storage) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
+ var sessions []models.MeetingSession
+ err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
+ Preload("Attendance").Preload("Attendance.Participant").
+ Order("started_at DESC").Limit(limit).Offset(offset).Find(&sessions).Error
+ return sessions, err
+}
+
+func (s *Storage) DeleteSession(id uint) error {
+ // Delete related logs first
+ if err := s.db.Where("session_id = ?", id).Delete(&models.ParticipantLog{}).Error; err != nil {
+ return err
+ }
+ // Delete related attendance
+ if err := s.db.Where("session_id = ?", id).Delete(&models.SessionAttendance{}).Error; err != nil {
+ return err
+ }
+ // Delete session
+ return s.db.Delete(&models.MeetingSession{}, id).Error
+}
+
+func (s *Storage) DeleteAllSessions() error {
+ // Delete all logs
+ if err := s.db.Exec("DELETE FROM participant_logs").Error; err != nil {
+ return err
+ }
+ // Delete all attendance
+ if err := s.db.Exec("DELETE FROM session_attendances").Error; err != nil {
+ return err
+ }
+ // Delete all sessions
+ return s.db.Exec("DELETE FROM meeting_sessions").Error
+}
+
+// Participant logs
+func (s *Storage) CreateParticipantLog(log *models.ParticipantLog) error {
+ return s.db.Create(log).Error
+}
+
+func (s *Storage) UpdateParticipantLog(log *models.ParticipantLog) error {
+ return s.db.Save(log).Error
+}
+
+// Attendance
+func (s *Storage) SetAttendance(sessionID, participantID uint, present, late bool) error {
+ attendance := models.SessionAttendance{
+ SessionID: sessionID,
+ ParticipantID: participantID,
+ Present: present,
+ JoinedLate: late,
+ }
+ return s.db.Create(&attendance).Error
+}
+
+// Settings
+func (s *Storage) GetSettings() (*models.Settings, error) {
+ var settings models.Settings
+ err := s.db.First(&settings).Error
+ return &settings, err
+}
+
+func (s *Storage) UpdateSettings(settings *models.Settings) error {
+ return s.db.Save(settings).Error
+}
+
+// Statistics
+func (s *Storage) GetAggregatedStats(from, to time.Time) (*models.AggregatedStats, error) {
+ var sessions []models.MeetingSession
+ err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
+ Preload("Attendance").
+ Where("started_at BETWEEN ? AND ?", from, to).
+ Where("completed = ?", true).
+ Find(&sessions).Error
+ if err != nil {
+ return nil, err
+ }
+
+ if len(sessions) == 0 {
+ return &models.AggregatedStats{}, nil
+ }
+
+ meeting, _ := s.GetMeeting()
+
+ totalTime := 0
+ overtimeSessions := 0
+ participantData := make(map[uint]*models.ParticipantBreakdown)
+
+ for _, session := range sessions {
+ totalTime += session.TotalDuration
+ if meeting != nil && session.TotalDuration > meeting.TimeLimit {
+ overtimeSessions++
+ }
+
+ for _, log := range session.ParticipantLogs {
+ if _, ok := participantData[log.ParticipantID]; !ok {
+ participantData[log.ParticipantID] = &models.ParticipantBreakdown{
+ ParticipantID: log.ParticipantID,
+ Name: log.Participant.Name,
+ }
+ }
+ pd := participantData[log.ParticipantID]
+ pd.SessionsAttended++
+ pd.TotalSpeakingTime += log.Duration
+ if log.Overtime {
+ pd.OvertimeCount++
+ }
+ if log.Skipped {
+ pd.SkipCount++
+ }
+ }
+ }
+
+ participants, _ := s.GetAllParticipants()
+ totalSessions := len(sessions)
+
+ breakdowns := make([]models.ParticipantBreakdown, 0, len(participantData))
+ for _, pd := range participantData {
+ if pd.SessionsAttended > 0 {
+ pd.AverageSpeakingTime = float64(pd.TotalSpeakingTime) / float64(pd.SessionsAttended)
+ }
+ pd.AttendanceRate = float64(pd.SessionsAttended) / float64(totalSessions) * 100
+ breakdowns = append(breakdowns, *pd)
+ }
+
+ avgAttendance := 0.0
+ if len(participants) > 0 && totalSessions > 0 {
+ totalAttended := 0
+ for _, pd := range participantData {
+ totalAttended += pd.SessionsAttended
+ }
+ avgAttendance = float64(totalAttended) / float64(totalSessions)
+ }
+
+ return &models.AggregatedStats{
+ TotalSessions: totalSessions,
+ TotalMeetingTime: totalTime,
+ AverageMeetingTime: float64(totalTime) / float64(totalSessions),
+ OvertimeSessions: overtimeSessions,
+ OvertimePercentage: float64(overtimeSessions) / float64(totalSessions) * 100,
+ AverageAttendance: avgAttendance,
+ ParticipantBreakdown: breakdowns,
+ }, nil
+}
+
+func (s *Storage) Close() error {
+ sqlDB, err := s.db.DB()
+ if err != nil {
+ return err
+ }
+ return sqlDB.Close()
+}
diff --git a/internal/timer/timer.go b/internal/timer/timer.go
new file mode 100644
index 0000000..f916b94
--- /dev/null
+++ b/internal/timer/timer.go
@@ -0,0 +1,428 @@
+package timer
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "daily-timer/internal/models"
+)
+
+type EventType string
+
+const (
+ EventTick EventType = "tick"
+ EventSpeakerWarning EventType = "speaker_warning"
+ EventSpeakerTimeUp EventType = "speaker_timeup"
+ EventMeetingWarning EventType = "meeting_warning"
+ EventMeetingTimeUp EventType = "meeting_timeup"
+ EventSpeakerChanged EventType = "speaker_changed"
+ EventMeetingEnded EventType = "meeting_ended"
+)
+
+type Event struct {
+ Type EventType `json:"type"`
+ State models.TimerState `json:"state"`
+}
+
+type Timer struct {
+ mu sync.RWMutex
+
+ running bool
+ paused bool
+
+ meetingStartTime time.Time
+ meetingElapsed time.Duration
+ meetingLimit time.Duration
+
+ speakerStartTime time.Time
+ speakerElapsed time.Duration
+ speakerLimit time.Duration
+
+ currentSpeakerID uint
+ currentSpeaker string
+ speakingOrder int
+ queue []models.QueuedSpeaker
+ allSpeakers []models.SpeakerInfo
+
+ warningThreshold time.Duration
+ speakerWarned bool
+ speakerTimeUpEmitted bool
+ meetingWarned bool
+
+ eventCh chan Event
+ ctx context.Context
+ cancel context.CancelFunc
+ pausedAt time.Time
+}
+
+func New(meetingLimitSec, warningThresholdSec int) *Timer {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Timer{
+ meetingLimit: time.Duration(meetingLimitSec) * time.Second,
+ warningThreshold: time.Duration(warningThresholdSec) * time.Second,
+ eventCh: make(chan Event, 100),
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+func (t *Timer) Events() <-chan Event {
+ return t.eventCh
+}
+
+func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.queue = speakers
+
+ // Initialize allSpeakers with pending status
+ t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
+ for i, s := range speakers {
+ t.allSpeakers[i] = models.SpeakerInfo{
+ ID: s.ID,
+ Name: s.Name,
+ TimeLimit: s.TimeLimit,
+ Order: i + 1,
+ Status: models.SpeakerStatusPending,
+ }
+ }
+}
+
+func (t *Timer) Start() {
+ t.mu.Lock()
+ if t.running {
+ t.mu.Unlock()
+ return
+ }
+
+ now := time.Now()
+ t.running = true
+ t.paused = false
+ t.meetingStartTime = now
+ t.meetingElapsed = 0
+ t.speakingOrder = 0
+ t.speakerWarned = false
+ t.meetingWarned = false
+
+ if len(t.queue) > 0 {
+ t.startNextSpeaker(now)
+ }
+ t.mu.Unlock()
+
+ go t.tick()
+}
+
+func (t *Timer) startNextSpeaker(now time.Time) {
+ if len(t.queue) == 0 {
+ return
+ }
+
+ // Mark previous speaker as done (only if they were speaking, not skipped)
+ if t.currentSpeakerID != 0 {
+ for i := range t.allSpeakers {
+ if t.allSpeakers[i].ID == t.currentSpeakerID {
+ if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
+ t.allSpeakers[i].Status = models.SpeakerStatusDone
+ }
+ break
+ }
+ }
+ }
+
+ speaker := t.queue[0]
+ t.queue = t.queue[1:]
+ t.currentSpeakerID = speaker.ID
+ t.currentSpeaker = speaker.Name
+ t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
+ t.speakerStartTime = now
+ t.speakerElapsed = 0
+ t.speakingOrder++
+ t.speakerWarned = false
+ t.speakerTimeUpEmitted = false
+
+ // Mark current speaker as speaking
+ t.updateSpeakerStatus(speaker.ID, models.SpeakerStatusSpeaking)
+}
+
+func (t *Timer) updateSpeakerStatus(id uint, status models.SpeakerStatus) {
+ for i := range t.allSpeakers {
+ if t.allSpeakers[i].ID == id {
+ t.allSpeakers[i].Status = status
+ break
+ }
+ }
+}
+
+func (t *Timer) moveSpeakerToEnd(id uint) {
+ var speaker models.SpeakerInfo
+ idx := -1
+ for i := range t.allSpeakers {
+ if t.allSpeakers[i].ID == id {
+ speaker = t.allSpeakers[i]
+ idx = i
+ break
+ }
+ }
+ if idx >= 0 {
+ // Remove from current position
+ t.allSpeakers = append(t.allSpeakers[:idx], t.allSpeakers[idx+1:]...)
+ // Add to end
+ t.allSpeakers = append(t.allSpeakers, speaker)
+ // Update order numbers
+ for i := range t.allSpeakers {
+ t.allSpeakers[i].Order = i + 1
+ }
+ }
+}
+
+func (t *Timer) NextSpeaker() {
+ t.mu.Lock()
+
+ if !t.running {
+ t.mu.Unlock()
+ return
+ }
+
+ now := time.Now()
+ if !t.paused {
+ t.speakerElapsed = now.Sub(t.speakerStartTime)
+ }
+
+ var eventType EventType
+ if len(t.queue) > 0 {
+ t.startNextSpeaker(now)
+ eventType = EventSpeakerChanged
+ } else {
+ t.running = false
+ t.paused = false
+ eventType = EventMeetingEnded
+ }
+ t.mu.Unlock()
+ t.emit(eventType)
+}
+
+func (t *Timer) SkipSpeaker() {
+ t.mu.Lock()
+
+ if !t.running || t.currentSpeakerID == 0 {
+ t.mu.Unlock()
+ return
+ }
+
+ // Mark current speaker as skipped in allSpeakers
+ t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusSkipped)
+
+ skipped := models.QueuedSpeaker{
+ ID: t.currentSpeakerID,
+ Name: t.currentSpeaker,
+ TimeLimit: int(t.speakerLimit.Seconds()),
+ }
+ t.queue = append(t.queue, skipped)
+
+ // Move skipped speaker to end of allSpeakers list
+ t.moveSpeakerToEnd(t.currentSpeakerID)
+
+ now := time.Now()
+ if len(t.queue) > 1 {
+ t.startNextSpeaker(now)
+ t.mu.Unlock()
+ t.emit(EventSpeakerChanged)
+ } else {
+ t.mu.Unlock()
+ }
+}
+
+func (t *Timer) RemoveFromQueue(speakerID uint) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ if !t.running {
+ return
+ }
+
+ // Don't remove current speaker - use SkipSpeaker for that
+ if speakerID == t.currentSpeakerID {
+ return
+ }
+
+ // Remove from queue
+ for i, s := range t.queue {
+ if s.ID == speakerID {
+ t.queue = append(t.queue[:i], t.queue[i+1:]...)
+ break
+ }
+ }
+
+ // Mark as skipped in allSpeakers and move to end
+ t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
+ t.moveSpeakerToEnd(speakerID)
+}
+
+func (t *Timer) Pause() {
+ t.mu.Lock()
+
+ if !t.running || t.paused {
+ t.mu.Unlock()
+ return
+ }
+
+ now := time.Now()
+ t.paused = true
+ t.pausedAt = now
+ t.speakerElapsed = now.Sub(t.speakerStartTime)
+ t.meetingElapsed = now.Sub(t.meetingStartTime)
+ t.mu.Unlock()
+ t.emit(EventTick)
+}
+
+func (t *Timer) Resume() {
+ t.mu.Lock()
+
+ if !t.running || !t.paused {
+ t.mu.Unlock()
+ return
+ }
+
+ pauseDuration := time.Since(t.pausedAt)
+ t.speakerStartTime = t.speakerStartTime.Add(pauseDuration)
+ t.meetingStartTime = t.meetingStartTime.Add(pauseDuration)
+ t.paused = false
+ t.mu.Unlock()
+ t.emit(EventTick)
+}
+
+func (t *Timer) Stop() {
+ t.mu.Lock()
+
+ if !t.running {
+ t.mu.Unlock()
+ return
+ }
+ // Mark current speaker as done before stopping
+ if t.currentSpeakerID != 0 {
+ t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone)
+ }
+ t.running = false
+ t.paused = false
+ t.mu.Unlock()
+ t.emit(EventMeetingEnded)
+}
+
+func (t *Timer) GetState() models.TimerState {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.buildState()
+}
+
+func (t *Timer) buildState() models.TimerState {
+ speakerElapsed := t.speakerElapsed
+ meetingElapsed := t.meetingElapsed
+
+ if t.running && !t.paused {
+ now := time.Now()
+ speakerElapsed = now.Sub(t.speakerStartTime)
+ meetingElapsed = now.Sub(t.meetingStartTime)
+ }
+
+ speakerOvertime := speakerElapsed > t.speakerLimit
+ meetingOvertime := meetingElapsed > t.meetingLimit
+ warning := !speakerOvertime && (t.speakerLimit-speakerElapsed) <= t.warningThreshold
+
+ // Copy allSpeakers to avoid data race and calculate total speakers time
+ allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
+ copy(allSpeakers, t.allSpeakers)
+
+ totalSpeakersTime := 0
+ for _, s := range t.allSpeakers {
+ totalSpeakersTime += s.TimeLimit
+ }
+
+ return models.TimerState{
+ Running: t.running,
+ Paused: t.paused,
+ CurrentSpeakerID: t.currentSpeakerID,
+ CurrentSpeaker: t.currentSpeaker,
+ SpeakerElapsed: int(speakerElapsed.Seconds()),
+ SpeakerLimit: int(t.speakerLimit.Seconds()),
+ MeetingElapsed: int(meetingElapsed.Seconds()),
+ MeetingLimit: int(t.meetingLimit.Seconds()),
+ SpeakerOvertime: speakerOvertime,
+ MeetingOvertime: meetingOvertime,
+ Warning: warning,
+ WarningSeconds: int(t.warningThreshold.Seconds()),
+ TotalSpeakersTime: totalSpeakersTime,
+ SpeakingOrder: t.speakingOrder,
+ TotalSpeakers: len(t.allSpeakers),
+ RemainingQueue: t.queue,
+ AllSpeakers: allSpeakers,
+ }
+}
+
+func (t *Timer) tick() {
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-t.ctx.Done():
+ return
+ case <-ticker.C:
+ t.mu.Lock()
+ if !t.running {
+ t.mu.Unlock()
+ return
+ }
+ if t.paused {
+ t.mu.Unlock()
+ continue
+ }
+
+ now := time.Now()
+ speakerElapsed := now.Sub(t.speakerStartTime)
+ meetingElapsed := now.Sub(t.meetingStartTime)
+
+ remaining := t.speakerLimit - speakerElapsed
+ if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
+ t.speakerWarned = true
+ t.mu.Unlock()
+ t.emit(EventSpeakerWarning)
+ continue
+ }
+
+ if !t.speakerTimeUpEmitted && speakerElapsed >= t.speakerLimit {
+ t.speakerTimeUpEmitted = true
+ t.mu.Unlock()
+ t.emit(EventSpeakerTimeUp)
+ continue
+ }
+
+ meetingRemaining := t.meetingLimit - meetingElapsed
+ if !t.meetingWarned && meetingRemaining <= t.warningThreshold && meetingRemaining > 0 {
+ t.meetingWarned = true
+ t.mu.Unlock()
+ t.emit(EventMeetingWarning)
+ continue
+ }
+
+ t.mu.Unlock()
+ t.emit(EventTick)
+ }
+ }
+}
+
+func (t *Timer) emit(eventType EventType) {
+ t.mu.RLock()
+ state := t.buildState()
+ t.mu.RUnlock()
+
+ select {
+ case t.eventCh <- Event{Type: eventType, State: state}:
+ default:
+ }
+}
+
+func (t *Timer) Close() {
+ t.cancel()
+ close(t.eventCh)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..7747f5d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "embed"
+ "log"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v2/pkg/options/mac"
+
+ "daily-timer/internal/app"
+ "daily-timer/internal/storage"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ store, err := storage.New()
+ if err != nil {
+ log.Fatalf("failed to initialize storage: %v", err)
+ }
+
+ application := app.New(store)
+
+ if err := wails.Run(&options.App{
+ Title: "Daily Timer",
+ Width: 480,
+ Height: 900,
+ MinWidth: 480,
+ MinHeight: 400,
+ MaxWidth: 480,
+ StartHidden: true,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
+ OnStartup: application.Startup,
+ OnDomReady: application.OnDomReady,
+ OnShutdown: application.Shutdown,
+ Bind: []interface{}{
+ application,
+ },
+ Mac: &mac.Options{
+ TitleBar: &mac.TitleBar{
+ TitlebarAppearsTransparent: true,
+ HideTitle: true,
+ HideTitleBar: false,
+ FullSizeContent: true,
+ UseToolbar: false,
+ },
+ About: &mac.AboutInfo{
+ Title: "Daily Timer",
+ Message: "Meeting timer with participant tracking\n\n© 2026 Movida.Biz",
+ },
+ },
+ }); err != nil {
+ log.Fatalf("error running application: %v", err)
+ }
+}
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..c71a0ef
--- /dev/null
+++ b/wails.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "Daily Timer",
+ "outputfilename": "daily-timer",
+ "frontend:install": "npm install",
+ "frontend:build": "npm run build",
+ "frontend:dev:watcher": "npm run dev",
+ "frontend:dev:serverUrl": "auto",
+ "author": {
+ "name": "Movida.Biz",
+ "email": "admin@movida.biz"
+ },
+ "info": {
+ "companyName": "Movida.Biz",
+ "productName": "Daily Timer",
+ "productVersion": "1.0.0",
+ "comments": "Meeting timer with participant time tracking",
+ "copyright": "Copyright © 2026 Movida.Biz"
+ }
+}