feat: initial daily-timer implementation

This commit is contained in:
Mikhail Kiselev
2026-02-08 05:17:37 +03:00
parent 537f72eb51
commit ef23291bdd
37 changed files with 7779 additions and 0 deletions
+38
View File
@@ -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
+21
View File
@@ -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
+74
View File
@@ -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"
+160
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daily Timer</title>
<style>
html,
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1b2636;
color: #e0e0e0;
overflow: hidden;
height: 100%;
}
#app {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+17
View File
@@ -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" }]
}
+1299
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
719a41f9088f999bf7b87d245f1e5231
+10
View File
@@ -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.
+466
View File
@@ -0,0 +1,466 @@
<script>
import { onMount, onDestroy } from 'svelte'
import Timer from './components/Timer.svelte'
import ParticipantList from './components/ParticipantList.svelte'
import Controls from './components/Controls.svelte'
import Settings from './components/Settings.svelte'
import History from './components/History.svelte'
import Setup from './components/Setup.svelte'
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
import { t, initLocale } from './lib/i18n'
let currentView = 'main'
let timerState = null
let meetingActive = false
let settings = null
let participants = []
let currentTime = ''
let clockInterval
function updateClock() {
const now = new Date()
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
onMount(async () => {
updateClock()
clockInterval = setInterval(updateClock, 1000)
initLocale()
await loadSettings()
await loadParticipants()
EventsOn('timer:tick', handleTimerEvent)
EventsOn('timer:speaker_warning', handleWarning)
EventsOn('timer:speaker_timeup', handleTimeUp)
EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged)
})
async function loadSettings() {
try {
settings = await GetSettings()
const width = settings?.windowWidth >= 480 ? settings.windowWidth : 800
if (settings?.windowFullHeight) {
// Get screen dimensions and use full height
try {
const screens = await ScreenGetAll()
if (screens && screens.length > 0) {
const primaryScreen = screens[0]
const height = primaryScreen.size?.height || 800
// Leave some space for dock/taskbar (approx 80px)
WindowSetSize(width, height - 80)
} else {
WindowSetSize(width, 800)
}
} catch (e) {
console.error('Failed to get screen size:', e)
WindowSetSize(width, 800)
}
} else {
WindowSetSize(width, 600)
}
} catch (e) {
console.error('Failed to load settings:', e)
}
}
onDestroy(() => {
if (clockInterval) clearInterval(clockInterval)
EventsOff('timer:tick')
EventsOff('timer:speaker_warning')
EventsOff('timer:speaker_timeup')
EventsOff('timer:meeting_warning')
EventsOff('timer:meeting_ended')
EventsOff('timer:speaker_changed')
})
function handleTimerEvent(state) {
timerState = state
}
function handleWarning(state) {
timerState = state
if (settings?.soundEnabled) {
playSound('warning')
}
}
function handleTimeUp(state) {
timerState = state
if (settings?.soundEnabled) {
playSound('timeup')
}
}
function handleMeetingWarning(state) {
timerState = state
}
function handleMeetingEnded(state) {
timerState = state
meetingActive = false
if (settings?.soundEnabled) {
playSound('meeting_end')
}
}
function handleSpeakerChanged(state) {
timerState = state
}
let audioContext = null
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
return audioContext
}
function playBeep(frequency, duration, type = 'sine') {
try {
const ctx = getAudioContext()
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.frequency.value = frequency
oscillator.type = type
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + duration)
} catch (e) {
console.error('Failed to play sound:', e)
}
}
function playSound(name) {
switch (name) {
case 'warning':
// Two short warning beeps
playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200)
break
case 'timeup':
// Descending tone sequence
playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500)
break
case 'meeting_end':
// Final chime - three notes
playBeep(523, 0.2) // C5
setTimeout(() => playBeep(659, 0.2), 200) // E5
setTimeout(() => playBeep(784, 0.4), 400) // G5
break
}
}
function handleMeetingStarted() {
meetingActive = true
currentView = 'main'
}
function handleSettingsLoaded(s) {
settings = s
}
async function handleSkipFromList(event) {
const { speakerId } = event.detail
try {
await RemoveFromQueue(speakerId)
} catch (e) {
console.error('Failed to remove speaker from queue:', e)
}
}
async function loadParticipants() {
try {
participants = await GetParticipants() || []
} catch (e) {
console.error('Failed to load participants:', e)
participants = []
}
}
async function handleQuickStart() {
if (participants.length === 0) return
const ids = participants.map(p => p.id)
const attendance = {}
participants.forEach(p => { attendance[p.id] = true })
try {
await StartMeeting(ids, attendance)
meetingActive = true
} catch (e) {
console.error('Failed to start meeting:', e)
alert('Ошибка запуска планёрки: ' + e)
}
}
// Reload participants when switching to main view
$: if (currentView === 'main' && !meetingActive) {
loadParticipants()
}
</script>
<div class="titlebar"></div>
<main>
<nav class="nav" class:hidden={meetingActive}>
<button
class:active={currentView === 'main'}
on:click={() => currentView = 'main'}
>
{$t('nav.timer')}
</button>
<button
class:active={currentView === 'setup'}
on:click={() => currentView = 'setup'}
>
{$t('nav.setup')}
</button>
<button
class:active={currentView === 'history'}
on:click={() => currentView = 'history'}
>
{$t('nav.history')}
</button>
<button
class:active={currentView === 'settings'}
on:click={() => currentView = 'settings'}
>
{$t('nav.settings')}
</button>
</nav>
<div class="content">
{#if currentView === 'main'}
{#if meetingActive && timerState}
<div class="timer-view">
<Timer {timerState} />
<ParticipantList {timerState} on:skip={handleSkipFromList} />
<Controls {timerState} on:stop={() => meetingActive = false} />
</div>
{:else if participants.length > 0}
<div class="ready-to-start">
<div class="current-clock">{currentTime}</div>
<h2>{$t('timer.readyToStart')}</h2>
<p>{$t('timer.registeredParticipants')}: {participants.length}</p>
<button class="start-btn big" on:click={handleQuickStart}>
{$t('setup.startMeeting')}
</button>
<button class="secondary-btn" on:click={() => currentView = 'setup'}>
{$t('timer.editParticipants')}
</button>
</div>
{:else}
<div class="no-meeting">
<div class="current-clock">{currentTime}</div>
<h2>{$t('timer.noParticipants')}</h2>
<p>{$t('timer.goToParticipants')}</p>
<button class="start-btn" on:click={() => currentView = 'setup'}>
{$t('nav.setup')}
</button>
</div>
{/if}
{:else if currentView === 'setup'}
<Setup on:started={handleMeetingStarted} />
{:else if currentView === 'history'}
<History />
{:else if currentView === 'settings'}
<Settings on:loaded={handleSettingsLoaded} />
{/if}
</div>
</main>
<style>
:global(body) {
margin: 0;
padding: 0;
}
main {
display: flex;
flex-direction: column;
height: 100vh;
padding-top: 28px; /* macOS titlebar */
}
/* Draggable titlebar area */
.titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 28px;
--wails-draggable: drag;
-webkit-app-region: drag;
background: transparent;
z-index: 1000;
}
.nav {
display: flex;
gap: 4px;
padding: 8px 12px;
background: #232f3e;
border-bottom: 1px solid #3d4f61;
--wails-draggable: drag;
-webkit-app-region: drag;
flex-wrap: wrap;
justify-content: center;
}
.nav.hidden {
display: none;
}
.nav button {
-webkit-app-region: no-drag;
padding: 8px 12px;
border: none;
background: transparent;
color: #9ca3af;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
}
.nav button:hover {
background: #3d4f61;
color: #e0e0e0;
}
.nav button.active {
background: #4a90d9;
color: white;
}
.content {
flex: 1;
overflow: auto;
padding: 12px;
padding-bottom: 64px;
}
.timer-view {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.no-meeting {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
gap: 16px;
}
.no-meeting h2 {
color: #e0e0e0;
margin: 0;
font-size: 24px;
}
.no-meeting p {
color: #9ca3af;
margin: 0;
}
.no-meeting .start-btn {
padding: 16px 32px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.no-meeting .start-btn:hover {
background: #15803d;
}
.ready-to-start {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
gap: 16px;
}
.ready-to-start h2 {
color: #e0e0e0;
margin: 0;
font-size: 22px;
}
.ready-to-start p {
color: #9ca3af;
margin: 0;
font-size: 16px;
}
.ready-to-start .start-btn.big {
padding: 20px 36px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 14px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
width: 100%;
max-width: 300px;
margin-bottom: 12px;
}
.ready-to-start .start-btn.big:hover {
background: #15803d;
transform: scale(1.02);
}
.ready-to-start .secondary-btn {
padding: 12px 24px;
background: transparent;
color: #9ca3af;
border: 1px solid #3d4f61;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.ready-to-start .secondary-btn:hover {
background: #3d4f61;
color: #e0e0e0;
}
.current-clock {
font-size: 32px;
color: #4a90d9;
font-family: 'SF Mono', 'Menlo', monospace;
font-weight: 600;
margin-bottom: 8px;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script>
import { createEventDispatcher } from 'svelte'
import { NextSpeaker, SkipSpeaker, PauseMeeting, ResumeMeeting, StopMeeting } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
$: isPaused = timerState?.paused || false
$: hasQueue = (timerState?.remainingQueue?.length || 0) > 0
async function handleNext() {
await NextSpeaker()
}
async function handleSkip() {
await SkipSpeaker()
}
async function handlePauseResume() {
if (isPaused) {
await ResumeMeeting()
} else {
await PauseMeeting()
}
}
async function handleStop() {
await StopMeeting()
dispatch('stop')
}
</script>
<div class="controls">
<button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')}
</button>
{#if hasQueue}
<button class="btn secondary" on:click={handleSkip}>
{$t('controls.skip')}
</button>
{/if}
<button class="btn secondary pause-btn" on:click={handlePauseResume}>
{isPaused ? '▶' : '⏸'}
</button>
<button class="btn danger" on:click={handleStop}>
</button>
</div>
<style>
.controls {
display: flex;
gap: 8px;
justify-content: center;
padding: 12px;
background: #232f3e;
border-radius: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
height: 44px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn.primary {
flex: 2;
background: #4a90d9;
color: white;
}
.btn.primary:hover {
background: #3a7bc8;
}
.btn.secondary {
flex: 1;
background: #3d4f61;
color: #e0e0e0;
}
.btn.secondary:hover {
background: #4d5f71;
}
.btn.pause-btn {
flex: 0;
min-width: 44px;
font-size: 18px;
}
.btn.danger {
flex: 0;
min-width: 44px;
font-size: 18px;
background: #7f1d1d;
color: #fca5a5;
}
.btn.danger:hover {
background: #991b1b;
}
</style>
+572
View File
@@ -0,0 +1,572 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { GetSessions, GetStatistics, ExportData, ExportCSV, DeleteSession, DeleteAllSessions } from '../../wailsjs/go/app/App'
import { t, locale } from '../lib/i18n'
let sessions = []
let stats = null
let loading = true
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
let dateTo = new Date().toISOString().split('T')[0]
let exporting = false
let showDeleteAllConfirm = false
let deletingSessionId = null
function handleKeydown(e) {
if (e.key === 'Escape') {
if (deletingSessionId !== null) deletingSessionId = null
if (showDeleteAllConfirm) showDeleteAllConfirm = false
}
}
onMount(async () => {
window.addEventListener('keydown', handleKeydown)
await loadData()
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
async function loadData() {
loading = true
try {
sessions = await GetSessions(50, 0)
stats = await GetStatistics(dateFrom, dateTo)
} catch (e) {
console.error('Failed to load history:', e)
}
loading = false
}
async function handleDeleteSession(id) {
deletingSessionId = id
}
async function confirmDeleteSession() {
if (!deletingSessionId) return
try {
await DeleteSession(deletingSessionId)
await loadData()
} catch (e) {
console.error('Failed to delete session:', e)
}
deletingSessionId = null
}
async function handleDeleteAll() {
showDeleteAllConfirm = true
}
async function confirmDeleteAll() {
try {
await DeleteAllSessions()
await loadData()
} catch (e) {
console.error('Failed to delete all sessions:', e)
}
showDeleteAllConfirm = false
}
async function handleExportJSON() {
exporting = true
try {
const path = await ExportData(dateFrom, dateTo)
if (path) {
alert('Exported to: ' + path)
}
} catch (e) {
alert('Export failed: ' + e)
}
exporting = false
}
async function handleExportCSV() {
exporting = true
try {
const path = await ExportCSV(dateFrom, dateTo)
if (path) {
alert('Exported to: ' + path)
}
} catch (e) {
alert('Export failed: ' + e)
}
exporting = false
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function formatDate(dateStr) {
const loc = $locale === 'ru' ? 'ru-RU' : 'en-US'
return new Date(dateStr).toLocaleDateString(loc, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<div class="history">
<div class="filters">
<div class="date-range">
<input type="date" bind:value={dateFrom} on:change={loadData} />
<span></span>
<input type="date" bind:value={dateTo} on:change={loadData} />
</div>
<div class="export-buttons">
<button on:click={handleExportJSON} disabled={exporting}>{$t('history.exportJSON')}</button>
<button on:click={handleExportCSV} disabled={exporting}>{$t('history.exportCSV')}</button>
{#if sessions.length > 0}
<button class="delete-all-btn" on:click={handleDeleteAll}>{$t('history.deleteAll')}</button>
{/if}
</div>
</div>
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else}
{#if stats}
<section class="stats-overview">
<h2>{$t('participants.stats')}</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.totalSessions}</div>
<div class="stat-label">{$t('participants.totalMeetings')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{formatTime(Math.round(stats.averageMeetingTime))}</div>
<div class="stat-label">{$t('history.avgTime')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.overtimePercentage.toFixed(0)}%</div>
<div class="stat-label">{$t('history.overtimeRate')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.averageAttendance.toFixed(1)}</div>
<div class="stat-label">{$t('history.avgAttendance')}</div>
</div>
</div>
{#if stats.participantBreakdown?.length > 0}
<h3>{$t('history.participantBreakdown')}</h3>
<div class="breakdown-table">
<div class="breakdown-header">
<span>{$t('history.name')}</span>
<span>{$t('history.sessions')}</span>
<span>{$t('history.avgTime')}</span>
<span>{$t('history.overtime')}</span>
<span>{$t('history.attendance')}</span>
</div>
{#each stats.participantBreakdown as p}
<div class="breakdown-row">
<span>{p.name}</span>
<span>{p.sessionsAttended}</span>
<span>{formatTime(Math.round(p.averageSpeakingTime))}</span>
<span class:overtime={p.overtimeCount > 0}>{p.overtimeCount}</span>
<span>{p.attendanceRate.toFixed(0)}%</span>
</div>
{/each}
</div>
{/if}
</section>
{/if}
<section class="sessions-list">
<h2>{$t('history.recentSessions')}</h2>
{#if sessions.length === 0}
<p class="empty">{$t('history.noSessions')}</p>
{:else}
{#each sessions as session}
<div class="session-card" class:overtime={session.totalDuration > 900}>
<div class="session-header">
<span class="session-date">{formatDate(session.startedAt)}</span>
<span class="session-duration">{formatTime(session.totalDuration)}</span>
{#if session.totalDuration > 900}
<span class="overtime-badge">OVERTIME</span>
{/if}
<button class="delete-session-btn" on:click={() => handleDeleteSession(session.id)} title={$t('history.deleteSession')}>🗑️</button>
</div>
{#if session.participantLogs?.length > 0}
<div class="session-participants">
{#each session.participantLogs as log}
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
<span class="log-duration">{formatTime(log.duration)}</span>
{#if log.overtime}
<span class="overtime-icon">⚠️</span>
{/if}
{#if log.skipped}
<span class="skipped-icon">⏭️</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</section>
{/if}
</div>
<!-- Delete Session Confirmation Modal -->
{#if deletingSessionId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={() => deletingSessionId = null}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal" on:click|stopPropagation>
<h3>{$t('history.confirmDeleteTitle')}</h3>
<p>{$t('history.confirmDeleteSession')}</p>
<div class="modal-buttons">
<button class="cancel-btn" on:click={() => deletingSessionId = null}>{$t('common.cancel')}</button>
<button class="confirm-btn" on:click={confirmDeleteSession}>{$t('common.delete')}</button>
</div>
</div>
</div>
{/if}
<!-- Delete All Confirmation Modal -->
{#if showDeleteAllConfirm}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={() => showDeleteAllConfirm = false}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal" on:click|stopPropagation>
<h3>{$t('history.confirmDeleteAllTitle')}</h3>
<p>{$t('history.confirmDeleteAll')}</p>
<div class="modal-buttons">
<button class="cancel-btn" on:click={() => showDeleteAllConfirm = false}>{$t('common.cancel')}</button>
<button class="confirm-btn danger" on:click={confirmDeleteAll}>{$t('history.deleteAll')}</button>
</div>
</div>
</div>
{/if}
<style>
.history {
max-width: 800px;
margin: 0 auto;
}
.filters {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.date-range input {
padding: 6px 8px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 13px;
max-width: 130px;
}
.date-range span {
color: #6b7280;
}
.export-buttons {
display: flex;
gap: 8px;
}
.export-buttons button {
padding: 8px 16px;
background: #3d4f61;
color: #e0e0e0;
border: none;
border-radius: 8px;
cursor: pointer;
}
.export-buttons button:hover {
background: #4d5f71;
}
.export-buttons button:disabled {
opacity: 0.5;
}
section {
background: #232f3e;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
h2 {
margin: 0 0 16px 0;
color: #e0e0e0;
}
h3 {
margin: 24px 0 12px 0;
color: #9ca3af;
font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
background: #1b2636;
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #4a90d9;
margin-bottom: 2px;
}
.stat-label {
font-size: 10px;
color: #6b7280;
text-transform: uppercase;
}
.breakdown-table {
background: #1b2636;
border-radius: 8px;
overflow: hidden;
}
.breakdown-header,
.breakdown-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
padding: 8px 10px;
font-size: 12px;
}
.breakdown-header {
background: #3d4f61;
font-size: 12px;
text-transform: uppercase;
color: #9ca3af;
}
.breakdown-row {
border-bottom: 1px solid #3d4f61;
}
.breakdown-row:last-child {
border-bottom: none;
}
.breakdown-row .overtime {
color: #ef4444;
}
.session-card {
background: #1b2636;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.session-card.overtime {
border-left: 4px solid #ef4444;
}
.session-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.session-date {
flex: 1;
color: #e0e0e0;
}
.session-duration {
font-family: 'SF Mono', 'Menlo', monospace;
color: #9ca3af;
}
.overtime-badge {
background: #ef4444;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.session-participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.participant-log {
display: flex;
align-items: center;
gap: 8px;
background: #232f3e;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
}
.participant-log.log-overtime {
border: 1px solid #ef4444;
}
.participant-log.skipped {
opacity: 0.6;
}
.log-order {
color: #6b7280;
font-size: 12px;
}
.log-name {
color: #e0e0e0;
}
.log-duration {
color: #9ca3af;
font-family: 'SF Mono', 'Menlo', monospace;
}
.loading, .empty {
text-align: center;
padding: 48px;
color: #6b7280;
}
.delete-all-btn {
background: #dc2626 !important;
border-color: #991b1b !important;
}
.delete-all-btn:hover {
background: #b91c1c !important;
}
.delete-session-btn {
margin-left: auto;
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
opacity: 0.5;
transition: opacity 0.2s;
padding: 4px 8px;
}
.delete-session-btn:hover {
opacity: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
}
.modal h3 {
margin: 0 0 12px 0;
color: #e0e0e0;
font-size: 18px;
}
.modal p {
margin: 0 0 20px 0;
color: #9ca3af;
font-size: 14px;
}
.modal-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-buttons button {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.cancel-btn {
background: #374151;
border: 1px solid #4b5563;
color: #e0e0e0;
}
.cancel-btn:hover {
background: #4b5563;
}
.confirm-btn {
background: #dc2626;
border: 1px solid #991b1b;
color: white;
}
.confirm-btn:hover {
background: #b91c1c;
}
.confirm-btn.danger {
background: #991b1b;
}
.confirm-btn.danger:hover {
background: #7f1d1d;
}
</style>
@@ -0,0 +1,166 @@
<script>
import { createEventDispatcher } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
$: allSpeakers = timerState?.allSpeakers || []
$: currentSpeakerId = timerState?.currentSpeakerId || 0
function handleSkip(speakerId) {
dispatch('skip', { speakerId })
}
</script>
<div class="participant-list">
<h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0}
<ul>
{#each allSpeakers as speaker}
<li class="speaker-item {speaker.status}">
<span class="order">{speaker.order}</span>
<span class="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
</button>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="empty"></p>
{/if}
</div>
<style>
.participant-list {
background: #232f3e;
border-radius: 12px;
padding: 12px;
flex: 1;
overflow: auto;
}
h3 {
margin: 0 0 8px 0;
color: #9ca3af;
font-size: 12px;
text-transform: uppercase;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.speaker-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: #1b2636;
border-radius: 8px;
margin-bottom: 6px;
transition: background-color 0.3s;
}
.speaker-item:last-child {
margin-bottom: 0;
}
.speaker-item.speaking {
background: #166534;
}
.speaker-item.done {
background: #1e3a5f;
}
.speaker-item.pending {
background: #1b2636;
}
.speaker-item.skipped {
background: repeating-linear-gradient(
45deg,
#3d2f1f,
#3d2f1f 5px,
#2d2318 5px,
#2d2318 10px
);
}
.order {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: #3d4f61;
border-radius: 50%;
font-size: 12px;
color: #9ca3af;
margin-right: 10px;
flex-shrink: 0;
}
.speaker-item.speaking .order {
background: #22c55e;
color: white;
}
.speaker-item.done .order {
background: #3b82f6;
color: white;
}
.name {
flex: 1;
font-size: 14px;
color: #e0e0e0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn {
margin-left: 8px;
padding: 4px 8px;
background: #6b7280;
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s, transform 0.1s;
}
.skip-btn:hover {
opacity: 1;
background: #9ca3af;
}
.skip-btn:active {
transform: scale(0.95);
}
.empty {
color: #6b7280;
text-align: center;
padding: 24px;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<script>
import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime'
import { t, locale, setLocale } from '../lib/i18n'
const dispatch = createEventDispatcher()
let settings = null
let meeting = null
let loading = true
let saving = false
let meetingLimitMin = 15
let defaultTimeMin = 2
let windowWidth = 800
let windowFullHeight = true
let audioContext = null
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
return audioContext
}
function playBeep(frequency, duration, type = 'sine') {
try {
const ctx = getAudioContext()
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.frequency.value = frequency
oscillator.type = type
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + duration)
} catch (e) {
console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
}
}
function testSound(name) {
switch (name) {
case 'warning':
playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200)
break
case 'timeup':
playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500)
break
case 'meeting_end':
playBeep(523, 0.2)
setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(784, 0.4), 400)
break
}
}
onMount(async () => {
await loadData()
})
async function loadData() {
loading = true
try {
console.log('Loading settings...')
settings = await GetSettings()
console.log('Settings loaded:', settings)
meeting = await GetMeeting()
console.log('Meeting loaded:', meeting)
if (meeting) {
meetingLimitMin = Math.floor(meeting.timeLimit / 60)
}
if (settings) {
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
windowWidth = settings.windowWidth || 800
windowFullHeight = settings.windowFullHeight !== false
}
dispatch('loaded', settings)
} catch (e) {
console.error('Failed to load settings:', e)
}
loading = false
}
async function saveSettings() {
saving = true
try {
settings.defaultParticipantTime = defaultTimeMin * 60
meeting.timeLimit = meetingLimitMin * 60
settings.windowWidth = Math.max(480, windowWidth)
settings.windowFullHeight = windowFullHeight
await UpdateSettings(settings)
await UpdateMeeting(meeting.name, meeting.timeLimit)
// Apply window size immediately
if (windowFullHeight) {
try {
const screens = await ScreenGetAll()
if (screens && screens.length > 0) {
const primaryScreen = screens[0]
const height = primaryScreen.size?.height || 800
WindowSetSize(settings.windowWidth, height - 80)
}
} catch (e) {
console.error('Failed to get screen size:', e)
}
} else {
WindowSetSize(settings.windowWidth, 600)
}
dispatch('loaded', settings)
} catch (e) {
console.error('Failed to save settings:', e)
alert('Failed to save settings: ' + e)
}
saving = false
}
</script>
<div class="settings">
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else if !meeting || !settings}
<div class="error">Failed to load settings. Please restart the app.</div>
{:else}
<section>
<h2>{$t('settings.language')}</h2>
<div class="field">
<div class="language-switcher">
<button class:active={$locale === 'ru'} on:click={() => setLocale('ru')}>🇷🇺 Русский</button>
<button class:active={$locale === 'en'} on:click={() => setLocale('en')}>🇺🇸 English</button>
</div>
</div>
</section>
<section>
<h2>{$t('settings.title')}</h2>
<div class="field">
<label for="meetingName">{$t('setup.title')}</label>
<input type="text" id="meetingName" bind:value={meeting.name} />
</div>
<div class="field">
<label for="meetingLimit">{$t('setup.totalTime')} ({$t('setup.minutes')})</label>
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
</div>
</section>
<section>
<h2>{$t('setup.speakerTime')}</h2>
<div class="field">
<label for="defaultTime">{$t('settings.defaultSpeakerTime')} ({$t('setup.minutes')})</label>
<input type="number" id="defaultTime" bind:value={defaultTimeMin} min="1" max="10" />
</div>
<div class="field">
<label for="warningThreshold">{$t('settings.warningTime')} ({$t('settings.seconds')})</label>
<input type="number" id="warningThreshold" bind:value={settings.warningThreshold} min="5" max="120" />
</div>
</section>
<section>
<h2>{$t('settings.sound')}</h2>
<div class="field checkbox">
<input type="checkbox" id="soundEnabled" bind:checked={settings.soundEnabled} />
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div>
<div class="sound-test-buttons">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button>
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
</div>
</section>
<section>
<h2>{$t('settings.windowWidth')}</h2>
<div class="field checkbox">
<input type="checkbox" id="windowFullHeight" bind:checked={windowFullHeight} />
<label for="windowFullHeight">{$t('settings.windowFullHeight')}</label>
</div>
<div class="field">
<label for="windowWidth">{$t('settings.windowWidthHint')}</label>
<input type="number" id="windowWidth" bind:value={windowWidth} min="480" max="1920" step="10" />
</div>
</section>
<button class="save-btn" on:click={saveSettings} disabled={saving}>
{saving ? $t('common.loading') : $t('settings.save')}
</button>
{/if}
</div>
<style>
.settings {
max-width: 100%;
margin: 0 auto;
}
.error {
background: #7f1d1d;
color: #fca5a5;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.loading {
text-align: center;
color: #9ca3af;
padding: 40px;
}
section {
background: #232f3e;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
h2 {
margin: 0 0 12px 0;
color: #e0e0e0;
font-size: 16px;
}
.field {
margin-bottom: 16px;
}
.field:last-child {
margin-bottom: 0;
}
label {
display: block;
color: #9ca3af;
font-size: 14px;
margin-bottom: 6px;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: 12px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 16px;
box-sizing: border-box;
color-scheme: dark;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 12px;
}
.field.checkbox input {
width: 20px;
height: 20px;
}
.field.checkbox label {
margin: 0;
color: #e0e0e0;
}
.save-btn {
width: 100%;
padding: 16px;
background: #4a90d9;
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover {
background: #3a7bc8;
}
.language-switcher {
display: flex;
gap: 8px;
}
.language-switcher button {
flex: 1;
padding: 12px 16px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.language-switcher button:hover {
border-color: #4a90d9;
color: #e0e0e0;
}
.language-switcher button.active {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 48px;
color: #6b7280;
}
.sound-test-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.test-btn {
flex: 1;
padding: 10px 12px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.test-btn:hover {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.test-btn:active {
transform: scale(0.97);
}
</style>
+826
View File
@@ -0,0 +1,826 @@
<script>
import { onMount, createEventDispatcher } from 'svelte'
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n'
const dispatch = createEventDispatcher()
let participants = []
let meeting = null
let selectedOrder = []
let attendance = {}
let loading = true
let newName = ''
let newTimeLimitMin = 2
// Edit mode
let editingId = null
let editName = ''
let editTimeLimitMin = 2
// Meeting name editing
let editingMeetingName = false
let meetingNameInput = ''
// Meeting time editing
let editingMeetingTime = false
let meetingTimeInput = 60
onMount(async () => {
await loadData()
})
async function loadData() {
loading = true
try {
participants = await GetParticipants()
meeting = await GetMeeting()
selectedOrder = participants.map(p => p.id)
attendance = {}
participants.forEach(p => {
attendance[p.id] = true
})
} catch (e) {
console.error('Failed to load data:', e)
}
loading = false
}
async function handleAddParticipant() {
if (!newName.trim()) return
try {
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60)
newName = ''
await loadData()
} catch (e) {
console.error('Failed to add participant:', e)
}
}
async function handleRemove(id) {
if (!confirm('Remove participant?')) return
try {
await DeleteParticipant(id)
await loadData()
} catch (e) {
console.error('Failed to remove participant:', e)
}
}
function startEdit(p) {
editingId = p.id
editName = p.name
editTimeLimitMin = Math.floor(p.timeLimit / 60)
}
function cancelEdit() {
editingId = null
editName = ''
editTimeLimitMin = 2
}
async function saveEdit() {
if (!editName.trim() || editingId === null) return
try {
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60)
editingId = null
await loadData()
} catch (e) {
console.error('Failed to update participant:', e)
}
}
function toggleAttendance(id) {
attendance[id] = !attendance[id]
attendance = attendance
}
// Drag and drop state
let draggedId = null
let dragOverId = null
function handleDragStart(e, id) {
draggedId = id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id.toString())
e.target.classList.add('dragging')
}
function handleDragEnd(e) {
e.target.classList.remove('dragging')
draggedId = null
dragOverId = null
}
function handleDragOver(e, id) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dragOverId = id
}
function handleDragLeave(e) {
dragOverId = null
}
async function handleDrop(e, targetId) {
e.preventDefault()
if (draggedId === null || draggedId === targetId) {
dragOverId = null
return
}
const fromIndex = selectedOrder.indexOf(draggedId)
const toIndex = selectedOrder.indexOf(targetId)
if (fromIndex !== -1 && toIndex !== -1) {
selectedOrder.splice(fromIndex, 1)
selectedOrder.splice(toIndex, 0, draggedId)
selectedOrder = selectedOrder
try {
await ReorderParticipants(selectedOrder)
} catch (err) {
console.error('Failed to save order:', err)
}
}
dragOverId = null
}
async function handleStart() {
const presentIds = selectedOrder.filter(id => attendance[id])
if (presentIds.length === 0) {
alert($t('setup.noParticipants'))
return
}
try {
await StartMeeting(presentIds, attendance)
dispatch('started')
} catch (e) {
console.error('Failed to start meeting:', e)
alert('Failed to start meeting: ' + e)
}
}
function getParticipant(id) {
return participants.find(p => p.id === id)
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function startEditMeetingName() {
meetingNameInput = meeting?.name || ''
editingMeetingName = true
}
function cancelEditMeetingName() {
editingMeetingName = false
meetingNameInput = ''
}
async function saveMeetingName() {
if (!meetingNameInput.trim()) return
try {
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600)
meeting = await GetMeeting()
editingMeetingName = false
} catch (e) {
console.error('Failed to update meeting name:', e)
}
}
function startEditMeetingTime() {
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60)
editingMeetingTime = true
}
function cancelEditMeetingTime() {
editingMeetingTime = false
}
async function saveMeetingTime() {
if (meetingTimeInput < 1) return
try {
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60)
meeting = await GetMeeting()
editingMeetingTime = false
} catch (e) {
console.error('Failed to update meeting time:', e)
}
}
function handleGlobalKeydown(e) {
if (e.key === 'Escape') {
if (editingId !== null) cancelEdit()
if (editingMeetingName) cancelEditMeetingName()
if (editingMeetingTime) cancelEditMeetingTime()
}
}
</script>
<svelte:window on:keydown={handleGlobalKeydown} />
<div class="setup">
<div class="header">
{#if editingMeetingName}
<div class="meeting-name-edit">
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
bind:value={meetingNameInput}
on:keydown={(e) => {
if (e.key === 'Enter') saveMeetingName()
if (e.key === 'Escape') cancelEditMeetingName()
}}
autofocus
/>
<button class="save-btn" on:click={saveMeetingName}>✓</button>
<button class="cancel-btn" on:click={cancelEditMeetingName}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<h1 on:click={startEditMeetingName} class="editable-title">
{meeting?.name || 'Daily Standup'}
<span class="edit-icon"></span>
</h1>
{/if}
{#if editingMeetingTime}
<div class="meeting-time-edit">
<!-- svelte-ignore a11y-autofocus -->
<input
type="number"
bind:value={meetingTimeInput}
min="1"
max="480"
on:keydown={(e) => {
if (e.key === 'Enter') saveMeetingTime()
if (e.key === 'Escape') cancelEditMeetingTime()
}}
autofocus
/>
<span class="time-suffix">{$t('setup.minutes')}</span>
<button class="save-btn" on:click={saveMeetingTime}>✓</button>
<button class="cancel-btn" on:click={cancelEditMeetingTime}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<p on:click={startEditMeetingTime} class="editable-time">
{$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)}
<span class="edit-icon"></span>
</p>
{/if}
</div>
<div class="add-participant">
<input
type="text"
bind:value={newName}
placeholder={$t('setup.namePlaceholder')}
on:keydown={(e) => e.key === 'Enter' && handleAddParticipant()}
/>
<input
type="number"
bind:value={newTimeLimitMin}
min="1"
max="10"
title="{$t('setup.speakerTime')} ({$t('setup.minutes')})"
/>
<span class="time-suffix">{$t('setup.minutes')}</span>
<button on:click={handleAddParticipant}>{$t('participants.add')}</button>
</div>
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else if participants.length === 0}
<div class="empty">
<p>{$t('setup.noParticipants')}</p>
</div>
{:else}
<div class="participant-order">
<h3>{$t('timer.queue')}</h3>
<p class="hint">{$t('setup.dragHint')}</p>
<ul>
{#each selectedOrder as id, i}
{@const p = getParticipant(id)}
{#if p}
<li
class:absent={!attendance[id]}
class:drag-over={dragOverId === id}
draggable="true"
on:dragstart={(e) => handleDragStart(e, id)}
on:dragend={handleDragEnd}
on:dragover={(e) => handleDragOver(e, id)}
on:dragleave={handleDragLeave}
on:drop={(e) => handleDrop(e, id)}
>
<span class="drag-handle"></span>
<span class="order-num">{i + 1}</span>
<button
class="attendance-toggle"
class:present={attendance[id]}
on:click={() => toggleAttendance(id)}
>
{attendance[id] ? '✓' : '✗'}
</button>
<span class="name">{p.name}</span>
<span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span>
<button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}"></button>
<button class="remove" on:click={() => handleRemove(id)}>×</button>
</li>
{/if}
{/each}
</ul>
</div>
{#if editingId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal-overlay" on:click={cancelEdit}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal" on:click|stopPropagation>
<h3>{$t('participants.edit')}</h3>
<div class="edit-field">
<label for="editName">{$t('participants.name')}</label>
<input id="editName" type="text" bind:value={editName} on:keydown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}} />
</div>
<div class="edit-field">
<label for="editTime">{$t('setup.speakerTime')} ({$t('setup.minutes')})</label>
<input id="editTime" type="number" bind:value={editTimeLimitMin} min="1" max="10" on:keydown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}} />
</div>
<div class="edit-actions">
<button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button>
<button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button>
</div>
</div>
</div>
{/if}
<div class="summary">
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span>
<span>{formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
</div>
<button class="start-btn" on:click={handleStart}>
{$t('setup.startMeeting')}
</button>
{/if}
</div>
<style>
.setup {
max-width: 600px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #e0e0e0;
display: block;
}
.header h1.editable-title {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.header h1.editable-title:hover {
color: #4a90d9;
}
.header h1 .edit-icon {
font-size: 16px;
opacity: 0.5;
}
.header h1.editable-title:hover .edit-icon {
opacity: 1;
}
.meeting-name-edit {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
}
.meeting-name-edit input {
padding: 8px 12px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
text-align: center;
min-width: 200px;
}
.meeting-name-edit .save-btn {
padding: 8px 12px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.meeting-name-edit .cancel-btn {
padding: 8px 12px;
background: #991b1b;
color: #fca5a5;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.header p {
color: #9ca3af;
margin: 8px 0 0 0;
}
.header p.editable-time {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.header p.editable-time:hover {
color: #4a90d9;
}
.header p .edit-icon {
font-size: 12px;
opacity: 0.5;
}
.header p.editable-time:hover .edit-icon {
opacity: 1;
}
.meeting-time-edit {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-top: 8px;
}
.meeting-time-edit input {
padding: 6px 8px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
text-align: center;
width: 60px;
color-scheme: dark;
}
.meeting-time-edit .time-suffix {
color: #9ca3af;
font-size: 14px;
}
.meeting-time-edit .save-btn {
padding: 6px 10px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.meeting-time-edit .cancel-btn {
padding: 6px 10px;
background: #991b1b;
color: #fca5a5;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.add-participant {
display: flex;
gap: 6px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.add-participant input[type="text"] {
flex: 1;
min-width: 120px;
padding: 10px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
}
.add-participant input[type="number"] {
width: 50px;
padding: 10px 6px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
text-align: center;
}
.time-suffix {
color: #9ca3af;
font-size: 12px;
display: flex;
align-items: center;
}
.add-participant button {
padding: 10px 16px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.add-participant button:hover {
background: #3a7bc8;
}
.participant-order h3 {
margin: 0 0 4px 0;
color: #e0e0e0;
}
.hint {
color: #6b7280;
font-size: 14px;
margin: 0 0 12px 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: #232f3e;
border-radius: 8px;
margin-bottom: 6px;
transition: opacity 0.2s;
flex-wrap: wrap;
}
li.absent {
opacity: 0.5;
}
li.drag-over {
border: 2px dashed #4a90d9;
background: #2a3a4e;
}
li:global(.dragging) {
opacity: 0.5;
background: #1b2636;
}
.drag-handle {
cursor: grab;
color: #6b7280;
font-size: 16px;
padding: 4px;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
.order-num {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #4a90d9;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
color: white;
}
.attendance-toggle {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
background: #7f1d1d;
color: #fca5a5;
}
.attendance-toggle.present {
background: #166534;
color: #4ade80;
}
.name {
flex: 1;
color: #e0e0e0;
font-size: 14px;
min-width: 80px;
word-break: break-word;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
color: #6b7280;
font-size: 12px;
}
.edit {
padding: 4px 8px;
background: transparent;
border: none;
color: #6b7280;
font-size: 14px;
cursor: pointer;
}
.edit:hover {
color: #4a90d9;
}
.remove {
padding: 4px 8px;
background: transparent;
border: none;
color: #6b7280;
font-size: 20px;
cursor: pointer;
}
.remove:hover {
color: #ef4444;
}
/* Edit Modal */
.edit-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-modal {
background: #232f3e;
border-radius: 12px;
padding: 20px;
width: 90%;
max-width: 320px;
}
.edit-modal h3 {
margin: 0 0 16px 0;
color: #e0e0e0;
font-size: 18px;
}
.edit-field {
margin-bottom: 12px;
}
.edit-field label {
display: block;
color: #9ca3af;
font-size: 12px;
margin-bottom: 4px;
}
.edit-field input {
width: 100%;
padding: 10px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
box-sizing: border-box;
color-scheme: dark;
}
.edit-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.edit-actions .cancel-btn {
flex: 1;
padding: 10px;
background: #3d4f61;
color: #e0e0e0;
border: none;
border-radius: 8px;
cursor: pointer;
}
.edit-actions .cancel-btn:hover {
background: #4d5f71;
}
.edit-actions .save-btn {
flex: 1;
padding: 10px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.edit-actions .save-btn:hover {
background: #3a7bc8;
}
.summary {
display: flex;
justify-content: space-between;
padding: 16px;
background: #232f3e;
border-radius: 8px;
margin-bottom: 16px;
color: #9ca3af;
}
.start-btn {
width: 100%;
padding: 20px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.start-btn:hover {
background: #15803d;
}
.loading, .empty {
text-align: center;
padding: 48px;
color: #6b7280;
}
</style>
+464
View File
@@ -0,0 +1,464 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
let currentTime = ''
let clockInterval
function updateClock() {
const now = new Date()
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
onMount(() => {
updateClock()
clockInterval = setInterval(updateClock, 1000)
})
onDestroy(() => {
if (clockInterval) clearInterval(clockInterval)
})
$: speakerTime = formatTime(timerState?.speakerElapsed || 0)
$: speakerLimit = formatTime(timerState?.speakerLimit || 0)
$: meetingTime = formatTime(timerState?.meetingElapsed || 0)
$: meetingLimit = formatTime(timerState?.meetingLimit || 0)
$: speakerProgress = timerState?.speakerLimit > 0
? Math.min((timerState.speakerElapsed / timerState.speakerLimit) * 100, 100)
: 0
$: warningZoneStart = timerState?.speakerLimit > 0 && timerState?.warningSeconds > 0
? Math.max(0, 100 - (timerState.warningSeconds / timerState.speakerLimit) * 100)
: 100
$: meetingProgress = timerState?.meetingLimit > 0
? Math.min((timerState.meetingElapsed / timerState.meetingLimit) * 100, 100)
: 0
// Yellow zone: time allocated for all speakers (as % of meeting limit)
$: speakersZoneEnd = timerState?.meetingLimit > 0 && timerState?.totalSpeakersTime > 0
? Math.min((timerState.totalSpeakersTime / timerState.meetingLimit) * 100, 100)
: 0
// Red zone: last 10% of meeting time
$: meetingDangerZoneStart = 90
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
function getTimerClass(state) {
if (!state) return ''
if (state.speakerOvertime) return 'overtime'
if (state.warning) return 'warning'
return ''
}
</script>
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
<div class="header-row">
<div class="current-clock">{currentTime}</div>
<div class="help-icon">
?
<div class="help-tooltip">
<div class="tooltip-title">Hotkeys</div>
<div class="tooltip-row"><span class="key">⌘N</span> Next speaker</div>
<div class="tooltip-row"><span class="key">⌘S</span> Skip speaker</div>
<div class="tooltip-row"><span class="key">Space</span> Pause/Resume</div>
<div class="tooltip-row"><span class="key">⌘Q</span> Stop meeting</div>
</div>
</div>
</div>
<div class="speaker-name">
{#if timerState?.currentSpeaker}
Сейчас говорит: {timerState.currentSpeaker}
{:else}
{$t('timer.noSpeaker')}
{/if}
</div>
<div class="speaker-section">
<div class="timer-display {getTimerClass(timerState)}">
<span class="time">{speakerTime}</span>
<span class="separator">/</span>
<span class="limit">{speakerLimit}</span>
</div>
<div class="progress-bar">
<div class="warning-zone" style="left: {warningZoneStart}%"></div>
<div
class="progress-fill {getTimerClass(timerState)}"
style="width: {speakerProgress}%"
></div>
</div>
{#if timerState?.speakerOvertime}
<div class="overtime-badge"></div>
{:else if timerState?.warning}
<div class="warning-badge">⚠️</div>
{/if}
</div>
<div class="meeting-section">
<div class="meeting-label">{$t('timer.totalTime')}</div>
<div class="meeting-time" class:overtime={timerState?.meetingOvertime}>
{meetingTime} / {meetingLimit}
</div>
<div class="progress-bar small meeting-progress">
<div class="buffer-zone" style="left: {speakersZoneEnd}%; width: {Math.max(0, meetingDangerZoneStart - speakersZoneEnd)}%"></div>
<div class="danger-zone" style="left: {meetingDangerZoneStart}%"></div>
<div
class="progress-fill {timerState?.meetingOvertime ? 'overtime' : ''}"
style="width: {meetingProgress}%"
></div>
</div>
</div>
<div class="status-bar">
<span>Speaker {timerState?.speakingOrder || 0} of {timerState?.totalSpeakers || 0}</span>
{#if timerState?.paused}
<span class="paused-badge">PAUSED</span>
{/if}
</div>
</div>
<style>
.timer-container {
position: relative;
background: #232f3e;
border-radius: 12px;
padding: 16px;
text-align: center;
transition: box-shadow 0.3s, background-color 0.5s;
}
.timer-container.warning {
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5), 0 0 40px rgba(251, 191, 36, 0.3);
animation: warningPulse 1.5s ease-in-out infinite;
}
.timer-container.overtime {
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6), 0 0 50px rgba(239, 68, 68, 0.4);
animation: overtimePulse 0.8s ease-in-out infinite;
}
.timer-container.warning::before,
.timer-container.overtime::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 12px 12px 0 0;
}
.timer-container.warning::before {
background: linear-gradient(90deg, transparent, #fbbf24, transparent);
animation: stripeSweep 2s linear infinite;
}
.timer-container.overtime::before {
background: linear-gradient(90deg, transparent, #ef4444, transparent);
animation: stripeSweep 1s linear infinite;
}
@keyframes warningPulse {
0%, 100% {
background-color: #232f3e;
box-shadow: 0 0 20px rgba(251, 191, 36, 0.4), 0 0 40px rgba(251, 191, 36, 0.2);
}
50% {
background-color: #2d3a28;
box-shadow: 0 0 30px rgba(251, 191, 36, 0.6), 0 0 60px rgba(251, 191, 36, 0.4);
}
}
@keyframes overtimePulse {
0%, 100% {
background-color: #232f3e;
box-shadow: 0 0 25px rgba(239, 68, 68, 0.5), 0 0 50px rgba(239, 68, 68, 0.3);
}
50% {
background-color: #3a2828;
box-shadow: 0 0 35px rgba(239, 68, 68, 0.8), 0 0 70px rgba(239, 68, 68, 0.5);
}
}
@keyframes stripeSweep {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.current-clock {
font-size: 14px;
color: #8899a6;
font-family: 'SF Mono', 'Menlo', monospace;
}
.speaker-name {
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
text-align: center;
margin-bottom: 16px;
}
.help-icon {
position: relative;
width: 20px;
height: 20px;
min-width: 20px;
border-radius: 50%;
background: #3d4f61;
color: #8899a6;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
cursor: help;
}
.help-tooltip {
display: none;
position: absolute;
top: 28px;
right: 0;
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 8px;
padding: 12px;
min-width: 160px;
z-index: 100;
text-align: left;
}
.help-icon:hover .help-tooltip {
display: block;
}
.tooltip-title {
font-size: 12px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #3d4f61;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #9ca3af;
margin-bottom: 4px;
}
.tooltip-row:last-child {
margin-bottom: 0;
}
.tooltip-row .key {
background: #3d4f61;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 10px;
color: #e0e0e0;
}
.speaker-section {
margin-bottom: 24px;
}
.timer-display {
font-size: 48px;
font-weight: 700;
font-family: 'SF Mono', 'Menlo', monospace;
margin-bottom: 12px;
transition: color 0.3s;
}
.timer-display .time {
color: #4ade80;
}
.timer-display.warning .time {
color: #fbbf24;
}
.timer-display.overtime .time {
color: #ef4444;
animation: pulse 1s ease-in-out infinite;
}
.timer-display .separator {
color: #6b7280;
margin: 0 4px;
font-size: 32px;
}
.timer-display .limit {
color: #6b7280;
font-size: 32px;
}
.progress-bar {
position: relative;
height: 8px;
background: #3d4f61;
border-radius: 4px;
overflow: hidden;
}
.warning-zone {
position: absolute;
top: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(239, 68, 68, 0.3),
rgba(239, 68, 68, 0.3) 3px,
rgba(127, 29, 29, 0.3) 3px,
rgba(127, 29, 29, 0.3) 6px
);
border-radius: 0 4px 4px 0;
}
.progress-bar.small {
height: 4px;
}
.meeting-progress {
position: relative;
}
.buffer-zone {
position: absolute;
top: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(251, 191, 36, 0.3),
rgba(251, 191, 36, 0.3) 2px,
rgba(180, 130, 20, 0.3) 2px,
rgba(180, 130, 20, 0.3) 4px
);
}
.danger-zone {
position: absolute;
top: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(239, 68, 68, 0.4),
rgba(239, 68, 68, 0.4) 2px,
rgba(127, 29, 29, 0.4) 2px,
rgba(127, 29, 29, 0.4) 4px
);
border-radius: 0 4px 4px 0;
}
.progress-fill {
height: 100%;
background: #4ade80;
transition: width 0.1s linear, background 0.3s;
border-radius: 4px;
}
.progress-fill.warning {
background: #fbbf24;
}
.progress-fill.overtime {
background: #ef4444;
}
.overtime-badge {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
background: #ef4444;
color: white;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
animation: pulse 1s ease-in-out infinite;
}
.warning-badge {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
background: #fbbf24;
color: #1b2636;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
}
.meeting-section {
padding: 16px;
background: #1b2636;
border-radius: 8px;
margin-bottom: 16px;
}
.meeting-label {
font-size: 12px;
color: #9ca3af;
text-transform: uppercase;
margin-bottom: 4px;
}
.meeting-time {
font-size: 24px;
font-family: 'SF Mono', 'Menlo', monospace;
color: #e0e0e0;
margin-bottom: 8px;
}
.meeting-time.overtime {
color: #ef4444;
}
.status-bar {
display: flex;
justify-content: center;
gap: 16px;
color: #9ca3af;
font-size: 14px;
}
.paused-badge {
background: #6b7280;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>
+318
View File
@@ -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);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app'),
});
export default app;
+10
View File
@@ -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,
},
});
+53
View File
@@ -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<models.Participant>;
export function DeleteAllSessions():Promise<void>;
export function DeleteParticipant(arg1:number):Promise<void>;
export function DeleteSession(arg1:number):Promise<void>;
export function ExportCSV(arg1:string,arg2:string):Promise<string>;
export function ExportData(arg1:string,arg2:string):Promise<string>;
export function GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>;
export function GetSession(arg1:number):Promise<models.MeetingSession>;
export function GetSessions(arg1:number,arg2:number):Promise<Array<models.MeetingSession>>;
export function GetSettings():Promise<models.Settings>;
export function GetSoundsDir():Promise<string>;
export function GetStatistics(arg1:string,arg2:string):Promise<models.AggregatedStats>;
export function GetTimerState():Promise<models.TimerState>;
export function NextSpeaker():Promise<void>;
export function PauseMeeting():Promise<void>;
export function RemoveFromQueue(arg1:number):Promise<void>;
export function ReorderParticipants(arg1:Array<number>):Promise<void>;
export function ResumeMeeting():Promise<void>;
export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
export function UpdateSettings(arg1:models.Settings):Promise<void>;
+103
View File
@@ -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);
}
+434
View File
@@ -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;
}
}
}
+24
View File
@@ -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 <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+249
View File
@@ -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<boolean>;
// [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<Size>;
// [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<Position>;
// [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<boolean>;
// [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<boolean>;
// [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<boolean>;
// [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<Screen[]>;
// [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<EnvironmentInfo>;
// [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<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [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
+242
View File
@@ -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);
}
+42
View File
@@ -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
)
+91
View File
@@ -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=
+481
View File
@@ -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
}
+72
View File
@@ -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
}
+98
View File
@@ -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"`
}
+331
View File
@@ -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()
}
+428
View File
@@ -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)
}
+61
View File
@@ -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)
}
}
+20
View File
@@ -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"
}
}