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 sessions.length > 0} + + {/if} +
+
+ + {#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')}

+
+
+ {$t('history.name')} + {$t('history.sessions')} + {$t('history.avgTime')} + {$t('history.overtime')} + {$t('history.attendance')} +
+ {#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}> +
+ {formatDate(session.startedAt)} + {formatTime(session.totalDuration)} + {#if session.totalDuration > 900} + OVERTIME + {/if} + +
+ + {#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} + + +{/if} + + +{#if showDeleteAllConfirm} + + +{/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} + + {: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')}

+ +
+
+ + +
+
+
+ +
+

{$t('settings.title')}

+ +
+ + +
+ +
+ + +
+
+ +
+

{$t('setup.speakerTime')}

+ +
+ + +
+ +
+ + +
+
+ +
+

{$t('settings.sound')}

+ +
+ + +
+ +
+ + + +
+
+ +
+

{$t('settings.windowWidth')}

+ +
+ + +
+ +
+ + +
+
+ + + {/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 @@ + + + + +
+
+ {#if editingMeetingName} +
+ + { + if (e.key === 'Enter') saveMeetingName() + if (e.key === 'Escape') cancelEditMeetingName() + }} + autofocus + /> + + +
+ {:else} + +

+ {meeting?.name || 'Daily Standup'} + +

+ {/if} + {#if editingMeetingTime} +
+ + { + if (e.key === 'Enter') saveMeetingTime() + if (e.key === 'Escape') cancelEditMeetingTime() + }} + autofocus + /> + {$t('setup.minutes')} + + +
+ {:else} + +

+ {$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)} + +

+ {/if} +
+ +
+ 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 @@ + + +
+
+
{currentTime}
+
+ ? +
+
Hotkeys
+
⌘N Next speaker
+
⌘S Skip speaker
+
Space Pause/Resume
+
⌘Q Stop meeting
+
+
+
+ +
+ {#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" + } +}