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
.gitignore vendored Normal file
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
.golangci.yml Normal file
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
Makefile Normal file
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
README.md
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
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

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
frontend/index.html Normal file
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
frontend/jsconfig.json Normal file
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
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
frontend/package.json Normal file
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
frontend/package.json.md5 Executable file
View File

@@ -0,0 +1 @@
719a41f9088f999bf7b87d245f1e5231

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
frontend/src/App.svelte Normal file
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>

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>

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>

View File

@@ -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>

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>

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>

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
frontend/src/lib/i18n.js Normal file
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
frontend/src/main.js Normal file
View File

@@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app'),
});
export default app;

10
frontend/vite.config.js Normal file
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
frontend/wailsjs/go/app/App.d.ts vendored Executable file
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
frontend/wailsjs/go/app/App.js Executable file
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
frontend/wailsjs/go/models.ts Executable file
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;
}
}
}

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
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
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

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
go.mod Normal file
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
go.sum Normal file
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
internal/app/app.go Normal file
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
internal/models/models.go Normal file
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
internal/models/types.go Normal file
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
internal/storage/storage.go Normal file
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
internal/timer/timer.go Normal file
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
main.go Normal file
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
wails.json Normal file
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"
}
}