32 Commits

Author SHA1 Message Date
Mikhail Kiselev
41bc35fd43 feat: add Jira URL integration and relay functionality
- Implemented OpenBrowserURL function to open Jira links
- Updated components to utilize the new Jira URL feature
- Added relay server to manage real-time URL updates
- Set default Jira URL in settings if not specified
2026-04-03 03:31:00 +03:00
Mikhail Kiselev
79214910f1 fix: update readyToStart label RU/EN 2026-03-13 04:20:41 +03:00
Mikhail Kiselev
9b65c95000 feat: open Jira in named window on start and speaker change 2026-03-13 04:19:05 +03:00
Mikhail Kiselev
577b1abe9b fix: use default Jira URL when meeting URL is not set 2026-03-13 04:10:53 +03:00
Mikhail Kiselev
6f153d7f32 feat: improve Jira URL UX and settings editing 2026-03-13 04:01:14 +03:00
Mikhail Kiselev
9c6a2dbf96 docs: update readme with jira integration feature 2026-03-13 01:49:28 +03:00
Mikhail Kiselev
93c91161ba feat: add jira filter url per participant and meeting jira url 2026-03-13 01:42:24 +03:00
Mikhail Kiselev
1620e12115 docs: mark drag-and-drop as completed 2026-02-11 00:21:32 +03:00
Mikhail Kiselev
545a18cf59 feat: global attendance store persists between views 2026-02-11 00:10:04 +03:00
Mikhail Kiselev
7e376f8211 fix: release-upload depends on release target 2026-02-10 23:58:51 +03:00
Mikhail Kiselev
c2a17185fd fix: save first speaker log on meeting start 2026-02-10 23:54:23 +03:00
Mikhail Kiselev
b2454f3e9e chore: remove unused CSS selector 2026-02-10 23:43:46 +03:00
Mikhail Kiselev
422ff362c3 chore: update wails bindings 2026-02-10 23:39:02 +03:00
Mikhail Kiselev
41c3fd4934 feat: save and restore window position 2026-02-10 23:36:06 +03:00
Mikhail Kiselev
6783ed8b0a docs: update version to v0.2.2. add click-to-switch feature 2026-02-10 23:16:13 +03:00
Mikhail Kiselev
fe6a41226c fix: layout, hotkeys, skip/switch speaker logic 2026-02-10 23:10:02 +03:00
Mikhail Kiselev
fc93ebbd26 fix: add async/await to timer event handlers for sound 2026-02-10 18:23:37 +03:00
Mikhail Kiselev
f0a8c32ea2 feat: show spent time in participant list, fix timer sounds 2026-02-10 18:15:18 +03:00
Mikhail Kiselev
850d1deed2 fix: remove outline from nav buttons 2026-02-10 18:03:48 +03:00
Mikhail Kiselev
5131a72983 fix: warm up AudioContext on first click, clean up code 2026-02-10 17:54:31 +03:00
Mikhail Kiselev
6dac14e0c1 fix: add delay after AudioContext resume and schedule offset 2026-02-10 17:48:34 +03:00
Mikhail Kiselev
482786a34b fix: await first playBeep in sound sequences 2026-02-10 16:53:40 +03:00
Mikhail Kiselev
906f504d49 fix: await AudioContext.resume() before playing 2026-02-10 16:45:56 +03:00
Mikhail Kiselev
649b1c039d chore: update wails bindings 2026-02-10 16:27:17 +03:00
Mikhail Kiselev
5fd85bfc50 fix: add AudioContext resume in main timer 2026-02-10 16:23:30 +03:00
Mikhail Kiselev
809f64b93d feat: add custom sound upload and fix localization 2026-02-10 16:19:39 +03:00
Mikhail Kiselev
30af8729b8 docs: add auto-update documentation 2026-02-10 16:03:39 +03:00
Mikhail Kiselev
cf0d60f40c fix: improve app restart after update 2026-02-10 16:00:06 +03:00
Mikhail Kiselev
9f5c9d568d chore: update wails bindings 2026-02-10 15:54:54 +03:00
Mikhail Kiselev
2b86eb9d20 feat: add checksum-based update detection 2026-02-10 15:53:26 +03:00
Mikhail Kiselev
75dc03b0fd fix: clean dist/ before release build 2026-02-10 15:47:48 +03:00
Mikhail Kiselev
a81540646e feat: add auto-update functionality 2026-02-10 15:39:17 +03:00
22 changed files with 2374 additions and 184 deletions

View File

@@ -1,17 +1,23 @@
.PHONY: dev build clean install frontend .PHONY: dev build clean install frontend
# Get version from git tag
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -X 'daily-timer/internal/version.Version=$(VERSION)' -X 'daily-timer/internal/version.GitCommit=$(GIT_COMMIT)' -X 'daily-timer/internal/version.BuildTime=$(BUILD_TIME)'
# Development (fixed ports: Vite 5173, Wails DevServer 34115) # Development (fixed ports: Vite 5173, Wails DevServer 34115)
dev: dev:
wails dev -devserver localhost:34115 wails dev -devserver localhost:34115
# Build for macOS # Build for macOS
build: build: lint
wails build -clean wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Build for macOS (universal binary) # Build for macOS (universal binary)
build-universal: build-universal: lint
wails build -clean -platform darwin/universal wails build -clean -platform darwin/universal -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
# Install frontend dependencies # Install frontend dependencies
@@ -51,21 +57,20 @@ deps:
init: deps frontend init: deps frontend
@echo "Project initialized. Run 'make dev' to start development." @echo "Project initialized. Run 'make dev' to start development."
# Get version from git tag
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
# Release - build and package # Release - build and package
release: release: lint
@echo "Building release $(VERSION)..." @echo "Building release $(VERSION)..."
wails build -clean wails build -clean -ldflags "$(LDFLAGS)"
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
@mkdir -p dist @rm -rf dist && mkdir -p dist
cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app" cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app"
@shasum -a 256 "build/bin/Daily Timer.app/Contents/MacOS/daily-timer" | awk '{print $$1}' > "dist/Daily-Timer-$(VERSION)-macos-arm64.sha256"
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip" @echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
@ls -lh dist/*.zip @echo "Checksum: $$(cat dist/Daily-Timer-$(VERSION)-macos-arm64.sha256)"
@ls -lh dist/*
# Release for both architectures # Release for both architectures
release-all: release-all: lint
@echo "Building release $(VERSION) for all platforms..." @echo "Building release $(VERSION) for all platforms..."
@mkdir -p dist @mkdir -p dist
GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64 GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64
@@ -76,7 +81,8 @@ release-all:
@ls -lh dist/*.zip @ls -lh dist/*.zip
# Upload release to Gitea (requires GITEA_TOKEN env var) # Upload release to Gitea (requires GITEA_TOKEN env var)
release-upload: # Depends on 'release' to ensure dist/ files are up-to-date
release-upload: release
@if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi @if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi
@echo "Creating release $(VERSION) on Gitea..." @echo "Creating release $(VERSION) on Gitea..."
@RELEASE_ID=$$(curl -s -X POST \ @RELEASE_ID=$$(curl -s -X POST \
@@ -86,7 +92,7 @@ release-upload:
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \ -d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
| jq -r '.id'); \ | jq -r '.id'); \
echo "Created release ID: $$RELEASE_ID"; \ echo "Created release ID: $$RELEASE_ID"; \
for file in dist/*.zip; do \ for file in dist/*; do \
filename=$$(basename "$$file"); \ filename=$$(basename "$$file"); \
echo "Uploading $$filename..."; \ echo "Uploading $$filename..."; \
curl -s -X POST \ curl -s -X POST \
@@ -96,8 +102,8 @@ release-upload:
done done
@echo "Done!" @echo "Done!"
# Full release cycle: build + upload # Full release cycle: build + upload (release-upload already depends on release)
release-publish: release release-upload release-publish: release-upload
# Help # Help
help: help:

View File

@@ -15,6 +15,8 @@
- 💾 **Экспорт** - экспорт данных в JSON или CSV - 💾 **Экспорт** - экспорт данных в JSON или CSV
- 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения - 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения
- 🌐 **Локализация** - русский и английский интерфейс - 🌐 **Локализация** - русский и английский интерфейс
- 🔗 **Jira интеграция** - автоматическое открытие Jira-фильтра в браузере при ходе участника
- 🔄 **Автообновление** - проверка и установка обновлений из приложения
## Скриншоты ## Скриншоты
@@ -36,6 +38,24 @@ _(Скоро)_
3. Переместить `Daily Timer.app` в `/Applications/` 3. Переместить `Daily Timer.app` в `/Applications/`
4. Убрать атрибут карантина (см. ниже) 4. Убрать атрибут карантина (см. ниже)
### Автообновление
Приложение поддерживает автоматическое обновление:
1. Открыть **Настройки** → секция "Обновления"
2. Нажать **Проверить сейчас**
3. Если доступно обновление → **Скачать и установить**
4. После установки нажать **Перезапустить**
Приложение автоматически:
- Скачивает новую версию
- Устанавливает в `/Applications/`
- Снимает карантин (`xattr -cr`)
- Перезапускается
**Обнаружение пересборок**: Если версия та же, но бинарник изменился (пересборка) - приложение также предложит обновление.
### Установка Wails CLI ### Установка Wails CLI
```bash ```bash
@@ -77,8 +97,9 @@ xattr -cr "Daily Timer.app"
1. **Добавить участников** - ввести имена и установить лимиты времени 1. **Добавить участников** - ввести имена и установить лимиты времени
2. **Установить общий лимит** - настроить длительность митинга (по умолчанию: 60 минут) 2. **Установить общий лимит** - настроить длительность митинга (по умолчанию: 60 минут)
3. **Упорядочить** - перетащить для изменения порядка выступлений 3. **Настроить Jira URL** - указать базовый URL доски Jira (опционально), для каждого участника задать quickFilter ID
4. **Отметить присутствие** - переключить статус присутствия/отсутствия 4. **Упорядочить** - перетащить для изменения порядка выступлений
5. **Отметить присутствие** - переключить статус присутствия/отсутствия
### Во время митинга ### Во время митинга
@@ -86,8 +107,9 @@ xattr -cr "Daily Timer.app"
2. Таймер показывает текущего спикера с обратным отсчётом 2. Таймер показывает текущего спикера с обратным отсчётом
3. Нажать **Следующий** для перехода (или ⌘N) 3. Нажать **Следующий** для перехода (или ⌘N)
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди 4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
5. Использовать **Пауза/Продолжить** для прерываний 5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
6. Нажать **Стоп** для досрочного завершения 6. Использовать **Пауза/Продолжить** для прерываний
7. Нажать **Стоп** для досрочного завершения
### Горячие клавиши ### Горячие клавиши
@@ -166,12 +188,13 @@ GITEA_TOKEN=<token> make release-publish
## Планы ## Планы
- [ ] Drag-and-drop для порядка участников - [x] Drag-and-drop для порядка участников
- [ ] Интеграция с Telegram (отправка сводки митинга) - [ ] Интеграция с Telegram (отправка сводки митинга)
- [ ] Интеграция с календарём (авто-расписание) - [ ] Интеграция с календарём (авто-расписание)
- [ ] Шаблоны команд - [ ] Шаблоны команд
- [ ] Облачная синхронизация - [ ] Облачная синхронизация
- [ ] Поддержка Windows/Linux - [ ] Поддержка Windows/Linux
- [x] Автообновление приложения
## Лицензия ## Лицензия

View File

@@ -7,8 +7,9 @@
import History from './components/History.svelte' import History from './components/History.svelte'
import Setup from './components/Setup.svelte' import Setup from './components/Setup.svelte'
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime' import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App' import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker, OpenBrowserURL } from '../wailsjs/go/app/App'
import { t, initLocale } from './lib/i18n' import { t, initLocale } from './lib/i18n'
import { attendance } from './lib/stores'
let currentView = 'main' let currentView = 'main'
let timerState = null let timerState = null
@@ -35,6 +36,28 @@
EventsOn('timer:meeting_warning', handleMeetingWarning) EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded) EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged) EventsOn('timer:speaker_changed', handleSpeakerChanged)
EventsOn('jira:open', (url) => { if (url) OpenBrowserURL(url) })
// Warm up AudioContext on first user interaction
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
document.removeEventListener('keydown', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
document.addEventListener('keydown', warmUpAudio)
}) })
async function loadSettings() { async function loadSettings() {
@@ -74,23 +97,24 @@
EventsOff('timer:meeting_warning') EventsOff('timer:meeting_warning')
EventsOff('timer:meeting_ended') EventsOff('timer:meeting_ended')
EventsOff('timer:speaker_changed') EventsOff('timer:speaker_changed')
EventsOff('jira:open')
}) })
function handleTimerEvent(state) { function handleTimerEvent(state) {
timerState = state timerState = state
} }
function handleWarning(state) { async function handleWarning(state) {
timerState = state timerState = state
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('warning') await playSound('warning')
} }
} }
function handleTimeUp(state) { async function handleTimeUp(state) {
timerState = state timerState = state
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('timeup') await playSound('timeup')
} }
} }
@@ -98,11 +122,11 @@
timerState = state timerState = state
} }
function handleMeetingEnded(state) { async function handleMeetingEnded(state) {
timerState = state timerState = state
meetingActive = false meetingActive = false
if (settings?.soundEnabled) { if (settings?.soundEnabled) {
playSound('meeting_end') await playSound('meeting_end')
} }
} }
@@ -141,24 +165,27 @@
} }
} }
function playSound(name) { async function playSound(name) {
// Ensure AudioContext is running (may be suspended after inactivity)
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
switch (name) { switch (name) {
case 'warning': case 'warning':
// Two short warning beeps
playBeep(880, 0.15) playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200) setTimeout(() => playBeep(880, 0.15), 200)
break break
case 'timeup': case 'timeup':
// Descending tone sequence
playBeep(1200, 0.2) playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250) setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500) setTimeout(() => playBeep(600, 0.3), 500)
break break
case 'meeting_end': case 'meeting_end':
// Final chime - three notes playBeep(523, 0.2)
playBeep(523, 0.2) // C5 setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(659, 0.2), 200) // E5 setTimeout(() => playBeep(784, 0.4), 400)
setTimeout(() => playBeep(784, 0.4), 400) // G5
break break
} }
} }
@@ -168,22 +195,37 @@
currentView = 'main' currentView = 'main'
} }
function handleSettingsLoaded(s) { function handleSettingsLoaded(event) {
settings = s settings = event.detail
} }
async function handleSkipFromList(event) { async function handleSkipFromList(event) {
const { speakerId } = event.detail const { speakerId } = event.detail
try { try {
await RemoveFromQueue(speakerId) // If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
if (timerState?.currentSpeakerId === speakerId) {
await SkipSpeaker()
} else {
await RemoveFromQueue(speakerId)
}
} catch (e) { } catch (e) {
console.error('Failed to remove speaker from queue:', e) console.error('Failed to skip speaker:', e)
}
}
async function handleSwitchSpeaker(event) {
const { speakerId } = event.detail
try {
await SwitchToSpeaker(speakerId)
} catch (e) {
console.error('Failed to switch to speaker:', e)
} }
} }
async function loadParticipants() { async function loadParticipants() {
try { try {
participants = await GetParticipants() || [] participants = await GetParticipants() || []
attendance.init(participants)
} catch (e) { } catch (e) {
console.error('Failed to load participants:', e) console.error('Failed to load participants:', e)
participants = [] participants = []
@@ -193,12 +235,16 @@
async function handleQuickStart() { async function handleQuickStart() {
if (participants.length === 0) return if (participants.length === 0) return
const ids = participants.map(p => p.id) const att = attendance.get()
const attendance = {} const presentIds = participants.filter(p => att[p.id]).map(p => p.id)
participants.forEach(p => { attendance[p.id] = true })
if (presentIds.length === 0) {
alert($t('setup.noParticipants'))
return
}
try { try {
await StartMeeting(ids, attendance) await StartMeeting(presentIds, att)
meetingActive = true meetingActive = true
} catch (e) { } catch (e) {
console.error('Failed to start meeting:', e) console.error('Failed to start meeting:', e)
@@ -242,12 +288,12 @@
</button> </button>
</nav> </nav>
<div class="content"> <div class="content" class:no-nav={meetingActive}>
{#if currentView === 'main'} {#if currentView === 'main'}
{#if meetingActive && timerState} {#if meetingActive && timerState}
<div class="timer-view"> <div class="timer-view">
<Timer {timerState} /> <Timer {timerState} jiraUrl={settings?.defaultJiraUrl || ''} />
<ParticipantList {timerState} on:skip={handleSkipFromList} /> <ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<Controls {timerState} on:stop={() => meetingActive = false} /> <Controls {timerState} on:stop={() => meetingActive = false} />
</div> </div>
{:else if participants.length > 0} {:else if participants.length > 0}
@@ -261,6 +307,9 @@
<button class="secondary-btn" on:click={() => currentView = 'setup'}> <button class="secondary-btn" on:click={() => currentView = 'setup'}>
{$t('timer.editParticipants')} {$t('timer.editParticipants')}
</button> </button>
{#if settings?.defaultJiraUrl}
<button class="secondary-btn" on:click={() => OpenBrowserURL(settings.defaultJiraUrl)}>🔗 Jira</button>
{/if}
</div> </div>
{:else} {:else}
<div class="no-meeting"> <div class="no-meeting">
@@ -309,6 +358,11 @@
} }
.nav { .nav {
position: fixed;
top: 32px;
left: 0;
right: 0;
z-index: 100;
display: flex; display: flex;
gap: 4px; gap: 4px;
padding: 8px 12px; padding: 8px 12px;
@@ -335,6 +389,7 @@
font-size: 13px; font-size: 13px;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
outline: none;
} }
.nav button:hover { .nav button:hover {
@@ -348,10 +403,17 @@
} }
.content { .content {
flex: 1; position: fixed;
overflow: auto; top: 84px; /* 32px titlebar + 52px nav height */
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
padding: 12px; padding: 12px;
padding-bottom: 64px; }
.content.no-nav {
top: 32px; /* Only titlebar when nav is hidden */
} }
.timer-view { .timer-view {

View File

@@ -30,8 +30,33 @@
await StopMeeting() await StopMeeting()
dispatch('stop') dispatch('stop')
} }
function handleKeydown(e) {
// ⌘N - Next speaker
if (e.metaKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
handleNext()
}
// ⌘S - Skip speaker
if (e.metaKey && e.key.toLowerCase() === 's') {
e.preventDefault()
handleSkip()
}
// Space - Pause/Resume
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault()
handlePauseResume()
}
// ⌘Q - Stop meeting
if (e.metaKey && e.key.toLowerCase() === 'q') {
e.preventDefault()
handleStop()
}
}
</script> </script>
<svelte:window on:keydown={handleKeydown} />
<div class="controls"> <div class="controls">
<button class="btn primary" on:click={handleNext}> <button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')} {hasQueue ? $t('controls.next') : $t('controls.stop')}

View File

@@ -6,8 +6,9 @@
let sessions = [] let sessions = []
let stats = null let stats = null
let loading = true let loading = true
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const _now = new Date()
let dateTo = new Date().toISOString().split('T')[0] let dateFrom = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, '0')}-01`
let dateTo = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, '0')}-${String(_now.getDate()).padStart(2, '0')}`
let exporting = false let exporting = false
let showDeleteAllConfirm = false let showDeleteAllConfirm = false
let deletingSessionId = null let deletingSessionId = null
@@ -205,7 +206,15 @@
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}> <div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span> <span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</span> <span class="log-name">{log.participant?.name || 'Unknown'}</span>
<span class="log-duration">{formatTime(log.duration)}</span> <span class="log-duration">
<span class:overtime={log.duration > (log.participant?.timeLimit || 0)}>
{formatTime(log.duration)}
</span>
{#if log.participant?.timeLimit}
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(log.participant.timeLimit)}</span>
{/if}
</span>
{#if log.overtime} {#if log.overtime}
<span class="overtime-icon">⚠️</span> <span class="overtime-icon">⚠️</span>
{/if} {/if}
@@ -463,6 +472,22 @@
.log-duration { .log-duration {
color: #9ca3af; color: #9ca3af;
font-family: 'SF Mono', 'Menlo', monospace; font-family: 'SF Mono', 'Menlo', monospace;
white-space: nowrap;
display: inline-flex;
align-items: center;
}
.log-duration .overtime {
color: #ef4444;
}
.log-duration .time-sep {
color: #6b7280;
margin: 0 3px;
}
.log-duration .time-limit {
color: #6b7280;
} }
.loading, .empty { .loading, .empty {

View File

@@ -1,31 +1,89 @@
<script> <script>
import { createEventDispatcher } from 'svelte' import { createEventDispatcher, tick } from 'svelte'
import { t } from '../lib/i18n' import { t } from '../lib/i18n'
export let timerState export let timerState
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let listEl
let lastSpeakingOrder = 0
$: allSpeakers = timerState?.allSpeakers || [] $: allSpeakers = timerState?.allSpeakers || []
$: currentSpeakerId = timerState?.currentSpeakerId || 0 $: currentSpeakerId = timerState?.currentSpeakerId || 0
$: currentElapsed = timerState?.speakerElapsed || 0
$: speakingOrder = timerState?.speakingOrder || 0
// Auto-scroll when speaker changes
$: if (speakingOrder !== lastSpeakingOrder && speakingOrder > 0) {
lastSpeakingOrder = speakingOrder
scrollToCurrentSpeaker()
}
async function scrollToCurrentSpeaker() {
await tick() // Wait for DOM update
if (!listEl) return
// Find the index of the current speaking participant
const speakingIndex = allSpeakers.findIndex(s => s.status === 'speaking')
if (speakingIndex < 0) return
// Scroll to show previous speaker at top (or current if first)
const targetIndex = Math.max(0, speakingIndex - 1)
const items = listEl.querySelectorAll('li')
if (items[targetIndex]) {
items[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function handleSkip(speakerId) { function handleSkip(speakerId) {
dispatch('skip', { speakerId }) dispatch('skip', { speakerId })
} }
function handleSwitch(speakerId) {
dispatch('switch', { speakerId })
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script> </script>
<div class="participant-list"> <div class="participant-list">
<h3>{$t('timer.participants')}</h3> <h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0} {#if allSpeakers.length > 0}
<ul> <ul bind:this={listEl}>
{#each allSpeakers as speaker} {#each allSpeakers as speaker}
<li class="speaker-item {speaker.status}"> <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<li
class="speaker-item {speaker.status}"
class:clickable={speaker.status !== 'speaking'}
on:click={() => speaker.status !== 'speaking' && handleSwitch(speaker.id)}
>
<span class="order">{speaker.order}</span> <span class="order">{speaker.order}</span>
<span class="name">{speaker.name}</span> <span class="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span> <span class="time-display">
{#if speaker.status === 'pending' || speaker.status === 'skipped'} {#if speaker.status === 'done'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}"> <span class="time-spent" class:overtime={speaker.timeSpent > speaker.timeLimit}>
{formatTime(speaker.timeSpent)}
</span>
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{:else if speaker.status === 'speaking'}
<span class="time-spent" class:overtime={currentElapsed > speaker.timeLimit}>
{formatTime(currentElapsed)}
</span>
<span class="time-sep">/</span>
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{:else}
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{/if}
</span>
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
</button> </button>
{/if} {/if}
@@ -85,6 +143,18 @@
background: #1b2636; background: #1b2636;
} }
.speaker-item.clickable {
cursor: pointer;
}
.speaker-item.clickable:hover {
background: #2d3f52;
}
.speaker-item.done.clickable:hover {
background: #2a4a6f;
}
.speaker-item.skipped { .speaker-item.skipped {
background: repeating-linear-gradient( background: repeating-linear-gradient(
45deg, 45deg,
@@ -129,13 +199,6 @@
white-space: nowrap; white-space: nowrap;
} }
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn { .skip-btn {
margin-left: 8px; margin-left: 8px;
padding: 4px 8px; padding: 4px 8px;
@@ -163,4 +226,30 @@
text-align: center; text-align: center;
padding: 24px; padding: 24px;
} }
.time-display {
display: inline-flex;
align-items: center;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.time-spent {
color: #4ade80;
}
.time-spent.overtime {
color: #ef4444;
}
.time-sep {
color: #6b7280;
margin: 0 3px;
}
.time-limit {
color: #6b7280;
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App' import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp, SelectCustomSound, GetCustomSoundPath, ClearCustomSound } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime' import { WindowSetSize, ScreenGetAll, EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
import { t, locale, setLocale } from '../lib/i18n' import { t, locale, setLocale } from '../lib/i18n'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@@ -12,10 +12,30 @@
let saving = false let saving = false
let meetingLimitMin = 15 let meetingLimitMin = 15
let defaultTimeMin = 2 let defaultTimeMin = 2
let defaultJiraUrl = ''
let editingDefaultJiraUrl = false
let defaultJiraUrlInput = ''
let windowWidth = 800 let windowWidth = 800
let windowFullHeight = true let windowFullHeight = true
let audioContext = null let audioContext = null
// Custom sounds state
let customSounds = {
warning: null,
timeup: null,
meeting_end: null
}
let audioElements = {}
// Update state
let currentVersion = 'dev'
let updateInfo = null
let checkingUpdate = false
let downloadingUpdate = false
let downloadProgress = 0
let updateError = null
let updateComplete = false
function getAudioContext() { function getAudioContext() {
if (!audioContext) { if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)() audioContext = new (window.AudioContext || window.webkitAudioContext)()
@@ -42,11 +62,17 @@
oscillator.stop(ctx.currentTime + duration) oscillator.stop(ctx.currentTime + duration)
} catch (e) { } catch (e) {
console.error('Failed to play sound:', e) console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
} }
} }
function testSound(name) { function testSound(name) {
// If custom sound exists, play it
if (customSounds[name]) {
playCustomSound(name)
return
}
// Otherwise play default beep sounds
switch (name) { switch (name) {
case 'warning': case 'warning':
playBeep(880, 0.15) playBeep(880, 0.15)
@@ -65,10 +91,140 @@
} }
} }
function playCustomSound(name) {
try {
if (!audioElements[name]) {
audioElements[name] = new Audio('file://' + customSounds[name])
}
audioElements[name].currentTime = 0
audioElements[name].play()
} catch (e) {
console.error('Failed to play custom sound:', e)
}
}
async function loadCustomSounds() {
const types = ['warning', 'timeup', 'meeting_end']
for (const type of types) {
try {
const path = await GetCustomSoundPath(type)
if (path) {
customSounds[type] = path
// Pre-create audio element
audioElements[type] = new Audio('file://' + path)
}
} catch (e) {
console.error(`Failed to get custom sound for ${type}:`, e)
}
}
customSounds = { ...customSounds } // Trigger reactivity
}
async function handleUploadSound(soundType) {
try {
const path = await SelectCustomSound(soundType)
if (path) {
customSounds[soundType] = path
// Recreate audio element with new file
audioElements[soundType] = new Audio('file://' + path)
customSounds = { ...customSounds }
}
} catch (e) {
console.error('Failed to upload sound:', e)
}
}
async function handleClearSound(soundType) {
try {
await ClearCustomSound(soundType)
customSounds[soundType] = null
delete audioElements[soundType]
customSounds = { ...customSounds }
} catch (e) {
console.error('Failed to clear sound:', e)
}
}
onMount(async () => { onMount(async () => {
await loadData() await loadData()
await loadCustomSounds()
// Load version and check for updates
try {
currentVersion = await GetVersion()
checkForUpdates()
} catch (e) {
console.error('Failed to get version:', e)
}
// Listen for update progress events
EventsOn('update:progress', (progress) => {
downloadProgress = progress
})
EventsOn('update:complete', () => {
downloadingUpdate = false
updateComplete = true
})
// Warm up AudioContext on first user interaction (for sound tests)
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
return () => {
EventsOff('update:progress')
EventsOff('update:complete')
document.removeEventListener('click', warmUpAudio)
}
}) })
async function checkForUpdates() {
checkingUpdate = true
updateError = null
updateInfo = null
try {
updateInfo = await CheckForUpdates()
} catch (e) {
console.error('Failed to check for updates:', e)
updateError = e.message || 'Unknown error'
} finally {
checkingUpdate = false
}
}
async function downloadAndInstall() {
downloadingUpdate = true
downloadProgress = 0
updateError = null
try {
await DownloadAndInstallUpdate()
} catch (e) {
console.error('Failed to download update:', e)
updateError = e.message || 'Download failed'
downloadingUpdate = false
}
}
async function restartApp() {
await RestartApp()
}
async function loadData() { async function loadData() {
loading = true loading = true
try { try {
@@ -83,6 +239,7 @@
} }
if (settings) { if (settings) {
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60) defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
defaultJiraUrl = settings.defaultJiraUrl || 'https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN'
windowWidth = settings.windowWidth || 800 windowWidth = settings.windowWidth || 800
windowFullHeight = settings.windowFullHeight !== false windowFullHeight = settings.windowFullHeight !== false
} }
@@ -98,6 +255,7 @@
saving = true saving = true
try { try {
settings.defaultParticipantTime = defaultTimeMin * 60 settings.defaultParticipantTime = defaultTimeMin * 60
settings.defaultJiraUrl = defaultJiraUrl.trim()
meeting.timeLimit = meetingLimitMin * 60 meeting.timeLimit = meetingLimitMin * 60
settings.windowWidth = Math.max(480, windowWidth) settings.windowWidth = Math.max(480, windowWidth)
settings.windowFullHeight = windowFullHeight settings.windowFullHeight = windowFullHeight
@@ -159,6 +317,29 @@
<label for="meetingLimit">{$t('settings.defaultTotalTime')}</label> <label for="meetingLimit">{$t('settings.defaultTotalTime')}</label>
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" /> <input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
</div> </div>
<div class="field">
<label for="defaultJiraUrl">{$t('settings.defaultJiraUrl')}</label>
{#if editingDefaultJiraUrl}
<div class="jira-edit-inline">
<!-- svelte-ignore a11y-autofocus -->
<input type="url" bind:value={defaultJiraUrlInput} autofocus
on:keydown={(e) => {
if (e.key === 'Enter') { defaultJiraUrl = defaultJiraUrlInput; editingDefaultJiraUrl = false }
if (e.key === 'Escape') editingDefaultJiraUrl = false
}}
/>
<button class="jira-inline-save" on:click={() => { defaultJiraUrl = defaultJiraUrlInput; editingDefaultJiraUrl = false }}>✓</button>
<button class="jira-inline-cancel" on:click={() => editingDefaultJiraUrl = false}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="jira-display" on:click={() => { defaultJiraUrlInput = defaultJiraUrl; editingDefaultJiraUrl = true }}>
<span class="jira-url-text">{defaultJiraUrl || '—'}</span>
<span class="jira-edit-icon"></span>
</div>
{/if}
</div>
</section> </section>
<section> <section>
@@ -183,10 +364,60 @@
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label> <label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div> </div>
<div class="sound-test-buttons"> <div class="sound-items">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button> <div class="sound-item">
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button> <div class="sound-info">
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button> <span class="sound-label">🔔 {$t('settings.testWarning')}</span>
{#if customSounds.warning}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('warning')}>📁</button>
{#if customSounds.warning}
<button type="button" class="clear-btn" on:click={() => handleClearSound('warning')}>✕</button>
{/if}
</div>
</div>
<div class="sound-item">
<div class="sound-info">
<span class="sound-label">{$t('settings.testTimeUp')}</span>
{#if customSounds.timeup}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('timeup')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('timeup')}>📁</button>
{#if customSounds.timeup}
<button type="button" class="clear-btn" on:click={() => handleClearSound('timeup')}>✕</button>
{/if}
</div>
</div>
<div class="sound-item">
<div class="sound-info">
<span class="sound-label">🏁 {$t('settings.testMeetingEnd')}</span>
{#if customSounds.meeting_end}
<span class="sound-status custom">{$t('settings.customSound')}</span>
{:else}
<span class="sound-status default">{$t('settings.defaultSound')}</span>
{/if}
</div>
<div class="sound-actions">
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>▶</button>
<button type="button" class="upload-btn" on:click={() => handleUploadSound('meeting_end')}>📁</button>
{#if customSounds.meeting_end}
<button type="button" class="clear-btn" on:click={() => handleClearSound('meeting_end')}>✕</button>
{/if}
</div>
</div>
</div> </div>
</section> </section>
@@ -204,6 +435,70 @@
</div> </div>
</section> </section>
<section class="updates-section">
<h2>{$t('updates.title')}</h2>
<div class="version-info">
<span class="version-label">{$t('updates.currentVersion')}:</span>
<span class="version-value">{currentVersion}</span>
</div>
{#if checkingUpdate}
<div class="update-status checking">
<span class="spinner"></span>
{$t('updates.checkingForUpdates')}
</div>
{:else if updateError}
<div class="update-status error">
{$t('updates.error')}: {updateError}
</div>
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{:else if updateComplete}
<div class="update-status success">
{$t('updates.restartRequired')}
</div>
<div class="update-buttons">
<button class="update-btn primary" on:click={restartApp}>
{$t('updates.restart')}
</button>
<button class="update-btn" on:click={() => updateComplete = false}>
{$t('updates.later')}
</button>
</div>
{:else if downloadingUpdate}
<div class="update-status downloading">
{$t('updates.downloading')} {Math.round(downloadProgress * 100)}%
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {downloadProgress * 100}%"></div>
</div>
{:else if updateInfo?.available}
<div class="update-status available">
{#if updateInfo.isRebuild}
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{:else}
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{/if}
</div>
<button class="update-btn primary" on:click={downloadAndInstall}>
{$t('updates.downloadAndInstall')}
</button>
{:else if updateInfo}
<div class="update-status uptodate">
{$t('updates.upToDate')}
</div>
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{:else}
<button class="update-btn" on:click={checkForUpdates}>
{$t('updates.checkNow')}
</button>
{/if}
</section>
<button class="save-btn" on:click={saveSettings} disabled={saving}> <button class="save-btn" on:click={saveSettings} disabled={saving}>
{saving ? $t('common.loading') : $t('settings.save')} {saving ? $t('common.loading') : $t('settings.save')}
</button> </button>
@@ -259,7 +554,8 @@
} }
input[type="text"], input[type="text"],
input[type="number"] { input[type="number"],
input[type="url"] {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 1px solid #3d4f61; border: 1px solid #3d4f61;
@@ -342,12 +638,6 @@
color: #6b7280; color: #6b7280;
} }
.sound-test-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.test-btn { .test-btn {
flex: 1; flex: 1;
padding: 10px 12px; padding: 10px 12px;
@@ -369,4 +659,279 @@
.test-btn:active { .test-btn:active {
transform: scale(0.97); transform: scale(0.97);
} }
/* Sound items */
.sound-items {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.sound-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 8px;
}
.sound-info {
display: flex;
align-items: center;
gap: 10px;
}
.sound-label {
font-size: 14px;
color: #e0e0e0;
}
.sound-status {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
.sound-status.custom {
background: #2a4a3a;
color: #6ee7b7;
}
.sound-status.default {
background: #3d4f61;
color: #9ca3af;
}
.sound-actions {
display: flex;
gap: 6px;
}
.sound-actions button {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #3d4f61;
border-radius: 6px;
background: #2a3a4e;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.sound-actions button:hover {
border-color: #4a90d9;
background: #3a4a5e;
color: #e0e0e0;
}
.sound-actions .clear-btn:hover {
border-color: #ef4444;
background: #4a2a2a;
color: #fca5a5;
}
/* Updates section */
.updates-section {
border: 1px solid #3d4f61;
}
.version-info {
display: flex;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.version-label {
color: #9ca3af;
}
.version-value {
color: #4a90d9;
font-family: monospace;
}
.update-status {
padding: 10px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.update-status.checking {
background: #1b2636;
color: #9ca3af;
display: flex;
align-items: center;
gap: 8px;
}
.update-status.error {
background: #7f1d1d;
color: #fca5a5;
}
.update-status.available {
background: #164e63;
color: #67e8f9;
}
.update-status.uptodate {
background: #14532d;
color: #86efac;
}
.update-status.downloading {
background: #1e3a5f;
color: #93c5fd;
}
.update-status.success {
background: #14532d;
color: #86efac;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #4a90d9;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-bar {
width: 100%;
height: 8px;
background: #1b2636;
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: #4a90d9;
transition: width 0.3s ease;
}
.update-buttons {
display: flex;
gap: 8px;
}
.update-btn {
padding: 10px 16px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.update-btn:hover {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.update-btn.primary {
background: #4a90d9;
border-color: #4a90d9;
color: white;
}
.update-btn.primary:hover {
background: #3b7dc9;
border-color: #3b7dc9;
}
.jira-display {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
transition: border-color 0.15s;
}
.jira-display:hover {
border-color: #4a90d9;
}
.jira-url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.jira-edit-icon {
flex-shrink: 0;
margin-left: 8px;
color: #9ca3af;
opacity: 0;
transition: opacity 0.15s;
}
.jira-display:hover .jira-edit-icon {
opacity: 1;
}
.jira-edit-inline {
display: flex;
gap: 8px;
align-items: center;
}
.jira-edit-inline input {
flex: 1;
padding: 12px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
box-sizing: border-box;
}
.jira-inline-save,
.jira-inline-cancel {
flex-shrink: 0;
width: 36px;
height: 42px;
border: none;
border-radius: 8px;
font-size: 18px;
cursor: pointer;
}
.jira-inline-save {
background: #22c55e;
color: white;
}
.jira-inline-cancel {
background: #ef4444;
color: white;
}
</style> </style>

View File

@@ -1,14 +1,14 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App' import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting, GetSettings, OpenBrowserURL } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n' import { t } from '../lib/i18n'
import { attendance } from '../lib/stores'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let participants = [] let participants = []
let meeting = null let meeting = null
let selectedOrder = [] let selectedOrder = []
let attendance = {}
let loading = true let loading = true
let newName = '' let newName = ''
let newTimeLimitMin = 2 let newTimeLimitMin = 2
@@ -17,15 +17,55 @@
let editingId = null let editingId = null
let editName = '' let editName = ''
let editTimeLimitMin = 2 let editTimeLimitMin = 2
let editJiraFilter = ''
// Quick jira filter edit
let quickFilterEditId = null
let quickFilterInput = ''
// Meeting name editing // Meeting name editing
let editingMeetingName = false let editingMeetingName = false
let meetingNameInput = '' let meetingNameInput = ''
let originalMeetingName = ''
// Meeting time editing // Meeting time editing
let editingMeetingTime = false let editingMeetingTime = false
let meetingTimeInput = 60 let meetingTimeInput = 60
let originalMeetingTime = 60
// Meeting Jira URL editing
let editingMeetingJiraUrl = false
let meetingJiraUrlInput = ''
let originalMeetingJiraUrl = ''
let defaultJiraUrl = ''
// Confirmation dialog when switching between editable fields
let confirmDialog = null
function isDirty() {
if (editingMeetingName) return meetingNameInput !== originalMeetingName
if (editingMeetingTime) return meetingTimeInput !== originalMeetingTime
if (editingMeetingJiraUrl) return meetingJiraUrlInput !== originalMeetingJiraUrl
return false
}
function getActiveEdit() {
if (editingMeetingName) return { save: saveMeetingName, cancel: cancelEditMeetingName }
if (editingMeetingTime) return { save: saveMeetingTime, cancel: cancelEditMeetingTime }
if (editingMeetingJiraUrl) return { save: saveMeetingJiraUrl, cancel: cancelEditMeetingJiraUrl }
return null
}
async function guardEdit(startFn) {
const active = getActiveEdit()
if (!active) { startFn(); return }
if (!isDirty()) { active.cancel(); startFn(); return }
confirmDialog = {
save: async () => { confirmDialog = null; await active.save(); startFn() },
discard: () => { confirmDialog = null; active.cancel(); startFn() },
cancel: () => { confirmDialog = null }
}
}
onMount(async () => { onMount(async () => {
await loadData() await loadData()
}) })
@@ -35,12 +75,11 @@
try { try {
participants = await GetParticipants() participants = await GetParticipants()
meeting = await GetMeeting() meeting = await GetMeeting()
const appSettings = await GetSettings()
defaultJiraUrl = appSettings?.defaultJiraUrl || 'https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN'
selectedOrder = participants.map(p => p.id) selectedOrder = participants.map(p => p.id)
attendance = {} attendance.init(participants)
participants.forEach(p => {
attendance[p.id] = true
})
} catch (e) { } catch (e) {
console.error('Failed to load data:', e) console.error('Failed to load data:', e)
} }
@@ -51,7 +90,7 @@
if (!newName.trim()) return if (!newName.trim()) return
try { try {
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60) await AddParticipant(newName.trim(), '', newTimeLimitMin * 60, '')
newName = '' newName = ''
await loadData() await loadData()
} catch (e) { } catch (e) {
@@ -74,19 +113,43 @@
editingId = p.id editingId = p.id
editName = p.name editName = p.name
editTimeLimitMin = Math.floor(p.timeLimit / 60) editTimeLimitMin = Math.floor(p.timeLimit / 60)
editJiraFilter = p.jiraFilter || ''
}
function startQuickFilterEdit(p) {
quickFilterEditId = p.id
quickFilterInput = p.jiraFilter || ''
}
function cancelQuickFilterEdit() {
quickFilterEditId = null
quickFilterInput = ''
}
async function saveQuickFilterEdit() {
const p = participants.find(x => x.id === quickFilterEditId)
if (!p) return
try {
await UpdateParticipant(p.id, p.name, '', p.timeLimit, quickFilterInput.trim())
quickFilterEditId = null
await loadData()
} catch (e) {
console.error('Failed to update jira filter:', e)
}
} }
function cancelEdit() { function cancelEdit() {
editingId = null editingId = null
editName = '' editName = ''
editTimeLimitMin = 2 editTimeLimitMin = 2
editJiraFilter = ''
} }
async function saveEdit() { async function saveEdit() {
if (!editName.trim() || editingId === null) return if (!editName.trim() || editingId === null) return
try { try {
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60) await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60, editJiraFilter.trim())
editingId = null editingId = null
await loadData() await loadData()
} catch (e) { } catch (e) {
@@ -95,8 +158,7 @@
} }
function toggleAttendance(id) { function toggleAttendance(id) {
attendance[id] = !attendance[id] attendance.toggle(id)
attendance = attendance
} }
// Drag and drop state // Drag and drop state
@@ -152,14 +214,15 @@
} }
async function handleStart() { async function handleStart() {
const presentIds = selectedOrder.filter(id => attendance[id]) const att = attendance.get()
const presentIds = selectedOrder.filter(id => att[id])
if (presentIds.length === 0) { if (presentIds.length === 0) {
alert($t('setup.noParticipants')) alert($t('setup.noParticipants'))
return return
} }
try { try {
await StartMeeting(presentIds, attendance) await StartMeeting(presentIds, att)
dispatch('started') dispatch('started')
} catch (e) { } catch (e) {
console.error('Failed to start meeting:', e) console.error('Failed to start meeting:', e)
@@ -178,7 +241,8 @@
} }
function startEditMeetingName() { function startEditMeetingName() {
meetingNameInput = meeting?.name || '' originalMeetingName = meeting?.name || ''
meetingNameInput = originalMeetingName
editingMeetingName = true editingMeetingName = true
} }
@@ -190,7 +254,7 @@
async function saveMeetingName() { async function saveMeetingName() {
if (!meetingNameInput.trim()) return if (!meetingNameInput.trim()) return
try { try {
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600) await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600, meeting?.jiraUrl || '')
meeting = await GetMeeting() meeting = await GetMeeting()
editingMeetingName = false editingMeetingName = false
} catch (e) { } catch (e) {
@@ -199,7 +263,8 @@
} }
function startEditMeetingTime() { function startEditMeetingTime() {
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60) originalMeetingTime = Math.floor((meeting?.timeLimit || 3600) / 60)
meetingTimeInput = originalMeetingTime
editingMeetingTime = true editingMeetingTime = true
} }
@@ -210,7 +275,7 @@
async function saveMeetingTime() { async function saveMeetingTime() {
if (meetingTimeInput < 1) return if (meetingTimeInput < 1) return
try { try {
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60) await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60, meeting?.jiraUrl || '')
meeting = await GetMeeting() meeting = await GetMeeting()
editingMeetingTime = false editingMeetingTime = false
} catch (e) { } catch (e) {
@@ -218,11 +283,35 @@
} }
} }
const DEFAULT_JIRA_URL = defaultJiraUrl
function startEditMeetingJiraUrl() {
originalMeetingJiraUrl = meeting?.jiraUrl || defaultJiraUrl
meetingJiraUrlInput = originalMeetingJiraUrl
editingMeetingJiraUrl = true
}
function cancelEditMeetingJiraUrl() {
editingMeetingJiraUrl = false
meetingJiraUrlInput = ''
}
async function saveMeetingJiraUrl() {
try {
await UpdateMeeting(meeting?.name || 'Daily Standup', meeting?.timeLimit || 3600, meetingJiraUrlInput.trim())
meeting = await GetMeeting()
editingMeetingJiraUrl = false
} catch (e) {
console.error('Failed to update meeting jira url:', e)
}
}
function handleGlobalKeydown(e) { function handleGlobalKeydown(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (editingId !== null) cancelEdit() if (editingId !== null) cancelEdit()
if (editingMeetingName) cancelEditMeetingName() if (editingMeetingName) cancelEditMeetingName()
if (editingMeetingTime) cancelEditMeetingTime() if (editingMeetingTime) cancelEditMeetingTime()
if (editingMeetingJiraUrl) cancelEditMeetingJiraUrl()
} }
} }
</script> </script>
@@ -248,11 +337,12 @@
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<h1 on:click={startEditMeetingName} class="editable-title"> <h1 on:click={() => guardEdit(startEditMeetingName)} class="editable-title">
{meeting?.name || 'Daily Standup'} {meeting?.name || 'Daily Standup'}
<span class="edit-icon"></span> <span class="edit-icon"></span>
</h1> </h1>
{/if} {/if}
<div class="meeting-info-block">
{#if editingMeetingTime} {#if editingMeetingTime}
<div class="meeting-time-edit"> <div class="meeting-time-edit">
<!-- svelte-ignore a11y-autofocus --> <!-- svelte-ignore a11y-autofocus -->
@@ -273,13 +363,51 @@
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<p on:click={startEditMeetingTime} class="editable-time"> <p on:click={() => guardEdit(startEditMeetingTime)} class="editable-time">
{$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)} {$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)}
<span class="edit-icon"></span> <span class="edit-icon"></span>
</p> </p>
{/if} {/if}
{#if editingMeetingJiraUrl}
<div class="meeting-jira-edit">
<!-- svelte-ignore a11y-autofocus -->
<input
type="url"
bind:value={meetingJiraUrlInput}
placeholder={$t('setup.jiraUrlPlaceholder')}
on:keydown={(e) => {
if (e.key === 'Enter') saveMeetingJiraUrl()
if (e.key === 'Escape') cancelEditMeetingJiraUrl()
}}
autofocus
/>
<button class="save-btn" on:click={saveMeetingJiraUrl}>✓</button>
<button class="cancel-btn" on:click={cancelEditMeetingJiraUrl}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<p on:click={() => guardEdit(startEditMeetingJiraUrl)} class="editable-jira">
{$t('setup.jiraUrl')}: {#if meeting?.jiraUrl}<span class="jira-url-value">{meeting.jiraUrl}</span>{:else}<span class="jira-default-label">(Default)</span>{/if}
<span class="edit-icon"></span>
</p>
{/if}
</div>
</div> </div>
{#if confirmDialog}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="confirm-overlay" on:click={confirmDialog.cancel}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="confirm-dialog" on:click|stopPropagation>
<p class="confirm-text">Сохранить изменения?</p>
<div class="confirm-actions">
<button class="confirm-discard" on:click={confirmDialog.discard}>Отменить</button>
<button class="confirm-save" on:click={confirmDialog.save}>Сохранить</button>
</div>
</div>
</div>
{/if}
<div class="add-participant"> <div class="add-participant">
<input <input
type="text" type="text"
@@ -314,7 +442,7 @@
{@const p = getParticipant(id)} {@const p = getParticipant(id)}
{#if p} {#if p}
<li <li
class:absent={!attendance[id]} class:absent={!$attendance[id]}
class:drag-over={dragOverId === id} class:drag-over={dragOverId === id}
draggable="true" draggable="true"
on:dragstart={(e) => handleDragStart(e, id)} on:dragstart={(e) => handleDragStart(e, id)}
@@ -329,14 +457,23 @@
<button <button
class="attendance-toggle" class="attendance-toggle"
class:present={attendance[id]} class:present={$attendance[id]}
on:click={() => toggleAttendance(id)} on:click={() => toggleAttendance(id)}
> >
{attendance[id] ? '✓' : '✗'} {$attendance[id] ? '✓' : '✗'}
</button> </button>
<span class="name">{p.name}</span> <span class="name">{p.name}</span>
<span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span> <span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<span
role="button"
tabindex="0"
class="url-indicator"
class:url-indicator--empty={!p.jiraFilter}
title={p.jiraFilter ? `${meeting?.jiraUrl}&quickFilter=${p.jiraFilter}` : $t('participants.jiraFilterEmpty')}
on:click|stopPropagation={() => startQuickFilterEdit(p)}
>🔗</span>
<button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}"></button> <button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}"></button>
<button class="remove" on:click={() => handleRemove(id)}>×</button> <button class="remove" on:click={() => handleRemove(id)}>×</button>
@@ -366,6 +503,13 @@
if (e.key === 'Escape') cancelEdit() if (e.key === 'Escape') cancelEdit()
}} /> }} />
</div> </div>
<div class="edit-field">
<label for="editJiraFilter">{$t('participants.jiraFilter')}</label>
<input id="editJiraFilter" type="text" bind:value={editJiraFilter} placeholder={$t('participants.jiraFilterPlaceholder')} on:keydown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}} />
</div>
<div class="edit-actions"> <div class="edit-actions">
<button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button> <button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button>
<button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button> <button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button>
@@ -374,9 +518,33 @@
</div> </div>
{/if} {/if}
{#if quickFilterEditId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal-overlay" on:click={cancelQuickFilterEdit}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal quick-filter-modal" on:click|stopPropagation>
<h3>quickFilter ID</h3>
<div class="edit-field">
<!-- svelte-ignore a11y-autofocus -->
<input id="quickFilterInput" type="text" bind:value={quickFilterInput}
placeholder={$t('participants.jiraFilterPlaceholder')}
autofocus
on:keydown={(e) => {
if (e.key === 'Enter') saveQuickFilterEdit()
if (e.key === 'Escape') cancelQuickFilterEdit()
}} />
</div>
<div class="edit-actions">
<button class="cancel-btn" on:click={cancelQuickFilterEdit}>{$t('common.cancel')}</button>
<button class="save-btn" on:click={saveQuickFilterEdit}>{$t('common.save')}</button>
</div>
</div>
</div>
{/if}
<div class="summary"> <div class="summary">
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span> <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> <span>{formatTime(selectedOrder.filter(id => $attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
</div> </div>
<button class="start-btn" on:click={handleStart}> <button class="start-btn" on:click={handleStart}>
@@ -393,11 +561,12 @@
.header { .header {
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 16px;
} }
.header h1 { .header h1 {
margin: 0; margin: 0;
font-size: 22px;
color: #e0e0e0; color: #e0e0e0;
display: block; display: block;
} }
@@ -469,9 +638,11 @@
.header p.editable-time { .header p.editable-time {
cursor: pointer; cursor: pointer;
display: inline-flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 14px;
} }
.header p.editable-time:hover { .header p.editable-time:hover {
@@ -532,6 +703,89 @@
cursor: pointer; cursor: pointer;
} }
.header p.editable-jira {
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
font-size: 14px;
margin: 4px 0 0 0;
}
.header p.editable-jira:hover {
color: #4a90d9;
}
.header p.editable-jira:hover .edit-icon {
opacity: 1;
}
.meeting-info-block {
margin: 12px 0 16px;
border: 1px solid #3d4f61;
border-radius: 10px;
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
.jira-url-value {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: bottom;
color: #6b7280;
}
.jira-default-label {
font-size: 11px;
color: #4b5563;
flex-shrink: 0;
}
.meeting-jira-edit {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-top: 6px;
}
.meeting-jira-edit input {
padding: 5px 8px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 12px;
width: 280px;
color-scheme: dark;
}
.meeting-jira-edit .save-btn {
padding: 5px 10px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.meeting-jira-edit .cancel-btn {
padding: 5px 10px;
background: #991b1b;
color: #fca5a5;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.add-participant { .add-participant {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -680,6 +934,28 @@
font-size: 12px; font-size: 12px;
} }
.url-indicator {
font-size: 12px;
opacity: 0.9;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
filter: sepia(1) saturate(3) hue-rotate(90deg);
}
.url-indicator:hover {
opacity: 1;
}
.url-indicator--empty {
opacity: 0.2;
filter: grayscale(1);
}
.url-indicator--empty:hover {
opacity: 0.5;
}
.edit { .edit {
padding: 4px 8px; padding: 4px 8px;
background: transparent; background: transparent;
@@ -720,6 +996,61 @@
z-index: 1000; z-index: 1000;
} }
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 120px;
z-index: 999;
}
.confirm-dialog {
background: #1e2d3d;
border: 1px solid #374151;
border-radius: 10px;
padding: 16px 20px;
min-width: 240px;
text-align: center;
}
.confirm-text {
color: #e0e0e0;
font-size: 14px;
margin: 0 0 12px 0;
}
.confirm-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.confirm-discard {
padding: 6px 14px;
background: #374151;
color: #9ca3af;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.confirm-save {
padding: 6px 14px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.edit-modal { .edit-modal {
background: #232f3e; background: #232f3e;
border-radius: 12px; border-radius: 12px;
@@ -728,6 +1059,10 @@
max-width: 320px; max-width: 320px;
} }
.edit-modal.quick-filter-modal {
max-width: 280px;
}
.edit-modal h3 { .edit-modal h3 {
margin: 0 0 16px 0; margin: 0 0 16px 0;
color: #e0e0e0; color: #e0e0e0;

View File

@@ -1,8 +1,10 @@
<script> <script>
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { t } from '../lib/i18n' import { t } from '../lib/i18n'
import { OpenBrowserURL } from '../../wailsjs/go/app/App'
export let timerState export let timerState
export let jiraUrl = ''
let currentTime = '' let currentTime = ''
let clockInterval let clockInterval
@@ -63,6 +65,9 @@
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}> <div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
<div class="header-row"> <div class="header-row">
<div class="current-clock">{currentTime}</div> <div class="current-clock">{currentTime}</div>
{#if jiraUrl}
<button class="jira-btn" on:click={() => OpenBrowserURL(jiraUrl)} title="Открыть Jira">🔗</button>
{/if}
<div class="help-icon"> <div class="help-icon">
? ?
<div class="help-tooltip"> <div class="help-tooltip">
@@ -204,6 +209,17 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.jira-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
opacity: 0.6;
transition: opacity 0.2s;
}
.jira-btn:hover { opacity: 1; }
.current-clock { .current-clock {
font-size: 14px; font-size: 14px;
color: #8899a6; color: #8899a6;

View File

@@ -23,10 +23,12 @@ export const translations = {
deselectAll: 'Снять выбор', deselectAll: 'Снять выбор',
startMeeting: 'Начать собрание', startMeeting: 'Начать собрание',
speakerTime: 'Время на спикера', speakerTime: 'Время на спикера',
totalTime: 'Общее время', totalTime: 'Общее время собрания',
minutes: 'мин', minutes: 'мин',
unlimited: 'Без ограничения', unlimited: 'Без ограничения',
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие', dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
jiraUrl: 'Jira Kanban URL',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
}, },
// Timer page // Timer page
@@ -42,7 +44,7 @@ export const translations = {
noSpeaker: 'Нет спикера', noSpeaker: 'Нет спикера',
noActiveMeeting: 'Собрание не запущено', noActiveMeeting: 'Собрание не запущено',
goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников', goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
readyToStart: 'Всё готово к началу', readyToStart: 'Всё готово к началу собрания',
editParticipants: 'Редактировать участников', editParticipants: 'Редактировать участников',
noParticipants: 'Участники не добавлены', noParticipants: 'Участники не добавлены',
registeredParticipants: 'Зарегистрированные участники', registeredParticipants: 'Зарегистрированные участники',
@@ -94,10 +96,16 @@ export const translations = {
sound: 'Звуковые уведомления', sound: 'Звуковые уведомления',
soundEnabled: 'Включены', soundEnabled: 'Включены',
soundDisabled: 'Выключены', soundDisabled: 'Выключены',
testWarning: 'Предупреждение',
testTimeUp: 'Время вышло',
testMeetingEnd: 'Конец собрания',
customSound: 'свой',
defaultSound: 'стандартный',
warningTime: 'Предупреждение за', warningTime: 'Предупреждение за',
seconds: 'сек', seconds: 'сек',
defaultSpeakerTime: 'Время на спикера по умолчанию', defaultSpeakerTime: 'Время на спикера по умолчанию',
defaultTotalTime: 'Общее время собрания (мин)', defaultTotalTime: 'Общее время собрания (мин)',
defaultJiraUrl: 'Jira URL Панели Kanban',
theme: 'Тема оформления', theme: 'Тема оформления',
themeDark: 'Тёмная', themeDark: 'Тёмная',
themeLight: 'Светлая', themeLight: 'Светлая',
@@ -108,6 +116,25 @@ export const translations = {
windowFullHeight: 'Окно на всю высоту экрана', windowFullHeight: 'Окно на всю высоту экрана',
}, },
// Updates
updates: {
title: 'Обновления',
currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...',
installing: 'Установка...',
restartRequired: 'Для завершения обновления требуется перезапуск',
restart: 'Перезапустить',
later: 'Позже',
error: 'Ошибка проверки обновлений',
downloadError: 'Ошибка загрузки обновления',
checkNow: 'Проверить сейчас',
},
// Participant management // Participant management
participants: { participants: {
title: 'Управление участниками', title: 'Управление участниками',
@@ -115,6 +142,9 @@ export const translations = {
edit: 'Редактировать', edit: 'Редактировать',
delete: 'Удалить', delete: 'Удалить',
name: 'Имя', name: 'Имя',
jiraFilter: 'Jira Kanban URL',
jiraFilterPlaceholder: 'quickFilter ID (напр. 12345)',
jiraFilterEmpty: 'Jira фильтр не задан — нажмите для настройки',
stats: 'Статистика', stats: 'Статистика',
avgSpeakTime: 'Среднее время выступления', avgSpeakTime: 'Среднее время выступления',
totalMeetings: 'Всего собраний', totalMeetings: 'Всего собраний',
@@ -172,10 +202,12 @@ export const translations = {
deselectAll: 'Deselect All', deselectAll: 'Deselect All',
startMeeting: 'Start Meeting', startMeeting: 'Start Meeting',
speakerTime: 'Speaker Time', speakerTime: 'Speaker Time',
totalTime: 'Total Time', totalTime: 'Total Meeting Time',
minutes: 'min', minutes: 'min',
unlimited: 'Unlimited', unlimited: 'Unlimited',
dragHint: 'drag to reorder, ✓/✗ attendance', dragHint: 'drag to reorder, ✓/✗ attendance',
jiraUrl: 'Jira Kanban URL',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
}, },
// Timer page // Timer page
@@ -191,7 +223,7 @@ export const translations = {
noSpeaker: 'No speaker', noSpeaker: 'No speaker',
noActiveMeeting: 'No active meeting', noActiveMeeting: 'No active meeting',
goToParticipants: 'Go to Participants to add participants', goToParticipants: 'Go to Participants to add participants',
readyToStart: 'Ready to start', readyToStart: 'Ready to start the meeting',
editParticipants: 'Edit participants', editParticipants: 'Edit participants',
noParticipants: 'No participants added', noParticipants: 'No participants added',
registeredParticipants: 'Registered participants', registeredParticipants: 'Registered participants',
@@ -243,10 +275,16 @@ export const translations = {
sound: 'Sound Notifications', sound: 'Sound Notifications',
soundEnabled: 'Enabled', soundEnabled: 'Enabled',
soundDisabled: 'Disabled', soundDisabled: 'Disabled',
testWarning: 'Warning',
testTimeUp: 'Time Up',
testMeetingEnd: 'Meeting End',
customSound: 'custom',
defaultSound: 'default',
warningTime: 'Warning before', warningTime: 'Warning before',
seconds: 'sec', seconds: 'sec',
defaultSpeakerTime: 'Default Speaker Time', defaultSpeakerTime: 'Default Speaker Time',
defaultTotalTime: 'Total meeting time (min)', defaultTotalTime: 'Total meeting time (min)',
defaultJiraUrl: 'Jira Kanban Board URL',
theme: 'Theme', theme: 'Theme',
themeDark: 'Dark', themeDark: 'Dark',
themeLight: 'Light', themeLight: 'Light',
@@ -257,6 +295,25 @@ export const translations = {
windowFullHeight: 'Full screen height window', windowFullHeight: 'Full screen height window',
}, },
// Updates
updates: {
title: 'Updates',
currentVersion: 'Current version',
checkingForUpdates: 'Checking for updates...',
updateAvailable: 'Update available',
rebuildAvailable: 'Rebuild available',
upToDate: 'You have the latest version',
downloadAndInstall: 'Download and install',
downloading: 'Downloading...',
installing: 'Installing...',
restartRequired: 'Restart required to complete the update',
restart: 'Restart',
later: 'Later',
error: 'Error checking for updates',
downloadError: 'Error downloading update',
checkNow: 'Check now',
},
// Participant management // Participant management
participants: { participants: {
title: 'Manage Participants', title: 'Manage Participants',
@@ -264,6 +321,9 @@ export const translations = {
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
name: 'Name', name: 'Name',
jiraFilter: 'Jira Kanban URL',
jiraFilterPlaceholder: 'quickFilter ID (e.g. 12345)',
jiraFilterEmpty: 'Jira filter not set — click to configure',
stats: 'Statistics', stats: 'Statistics',
avgSpeakTime: 'Avg Speaking Time', avgSpeakTime: 'Avg Speaking Time',
totalMeetings: 'Total Meetings', totalMeetings: 'Total Meetings',

View File

@@ -0,0 +1,56 @@
import { writable, get } from 'svelte/store';
function createAttendanceStore() {
const { subscribe, set, update } = writable({});
return {
subscribe,
// Initialize attendance for all participants (default: true)
init(participants) {
const current = get({ subscribe });
const newAttendance = {};
for (const p of participants) {
// Keep existing value or default to true
newAttendance[p.id] = current[p.id] !== undefined ? current[p.id] : true;
}
set(newAttendance);
},
// Toggle attendance for a participant
toggle(id) {
update((att) => {
att[id] = !att[id];
return { ...att };
});
},
// Set attendance for a participant
set(id, present) {
update((att) => {
att[id] = present;
return { ...att };
});
},
// Get current attendance object
get() {
return get({ subscribe });
},
// Reset all to true
resetAll() {
update((att) => {
const reset = {};
for (const id in att) {
reset[id] = true;
}
return reset;
});
},
};
}
export const attendance = createAttendanceStore();

View File

@@ -1,8 +1,13 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {models} from '../models'; import {models} from '../models';
import {updater} from '../models';
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>; export function AddParticipant(arg1:string,arg2:string,arg3:number,arg4:string):Promise<models.Participant>;
export function CheckForUpdates():Promise<updater.UpdateInfo>;
export function ClearCustomSound(arg1:string):Promise<void>;
export function DeleteAllSessions():Promise<void>; export function DeleteAllSessions():Promise<void>;
@@ -10,10 +15,14 @@ export function DeleteParticipant(arg1:number):Promise<void>;
export function DeleteSession(arg1:number):Promise<void>; export function DeleteSession(arg1:number):Promise<void>;
export function DownloadAndInstallUpdate():Promise<void>;
export function ExportCSV(arg1:string,arg2:string):Promise<string>; export function ExportCSV(arg1:string,arg2:string):Promise<string>;
export function ExportData(arg1:string,arg2:string):Promise<string>; export function ExportData(arg1:string,arg2:string):Promise<string>;
export function GetCustomSoundPath(arg1:string):Promise<string>;
export function GetMeeting():Promise<models.Meeting>; export function GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>; export function GetParticipants():Promise<Array<models.Participant>>;
@@ -30,24 +39,36 @@ export function GetStatistics(arg1:string,arg2:string):Promise<models.Aggregated
export function GetTimerState():Promise<models.TimerState>; export function GetTimerState():Promise<models.TimerState>;
export function GetVersion():Promise<string>;
export function NextSpeaker():Promise<void>; export function NextSpeaker():Promise<void>;
export function OpenBrowserURL(arg1:string):Promise<void>;
export function PauseMeeting():Promise<void>; export function PauseMeeting():Promise<void>;
export function RemoveFromQueue(arg1:number):Promise<void>; export function RemoveFromQueue(arg1:number):Promise<void>;
export function ReorderParticipants(arg1:Array<number>):Promise<void>; export function ReorderParticipants(arg1:Array<number>):Promise<void>;
export function RestartApp():Promise<void>;
export function ResumeMeeting():Promise<void>; export function ResumeMeeting():Promise<void>;
export function SaveWindowPosition():Promise<void>;
export function SelectCustomSound(arg1:string):Promise<string>;
export function SkipSpeaker():Promise<void>; export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>; export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():Promise<void>; export function StopMeeting():Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>; export function SwitchToSpeaker(arg1:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>; export function UpdateMeeting(arg1:string,arg2:number,arg3:string):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number,arg5:string):Promise<void>;
export function UpdateSettings(arg1:models.Settings):Promise<void>; export function UpdateSettings(arg1:models.Settings):Promise<void>;

View File

@@ -2,8 +2,16 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function AddParticipant(arg1, arg2, arg3) { export function AddParticipant(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3); return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3, arg4);
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function ClearCustomSound(arg1) {
return window['go']['app']['App']['ClearCustomSound'](arg1);
} }
export function DeleteAllSessions() { export function DeleteAllSessions() {
@@ -18,6 +26,10 @@ export function DeleteSession(arg1) {
return window['go']['app']['App']['DeleteSession'](arg1); return window['go']['app']['App']['DeleteSession'](arg1);
} }
export function DownloadAndInstallUpdate() {
return window['go']['app']['App']['DownloadAndInstallUpdate']();
}
export function ExportCSV(arg1, arg2) { export function ExportCSV(arg1, arg2) {
return window['go']['app']['App']['ExportCSV'](arg1, arg2); return window['go']['app']['App']['ExportCSV'](arg1, arg2);
} }
@@ -26,6 +38,10 @@ export function ExportData(arg1, arg2) {
return window['go']['app']['App']['ExportData'](arg1, arg2); return window['go']['app']['App']['ExportData'](arg1, arg2);
} }
export function GetCustomSoundPath(arg1) {
return window['go']['app']['App']['GetCustomSoundPath'](arg1);
}
export function GetMeeting() { export function GetMeeting() {
return window['go']['app']['App']['GetMeeting'](); return window['go']['app']['App']['GetMeeting']();
} }
@@ -58,10 +74,18 @@ export function GetTimerState() {
return window['go']['app']['App']['GetTimerState'](); return window['go']['app']['App']['GetTimerState']();
} }
export function GetVersion() {
return window['go']['app']['App']['GetVersion']();
}
export function NextSpeaker() { export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker'](); return window['go']['app']['App']['NextSpeaker']();
} }
export function OpenBrowserURL(arg1) {
return window['go']['app']['App']['OpenBrowserURL'](arg1);
}
export function PauseMeeting() { export function PauseMeeting() {
return window['go']['app']['App']['PauseMeeting'](); return window['go']['app']['App']['PauseMeeting']();
} }
@@ -74,10 +98,22 @@ export function ReorderParticipants(arg1) {
return window['go']['app']['App']['ReorderParticipants'](arg1); return window['go']['app']['App']['ReorderParticipants'](arg1);
} }
export function RestartApp() {
return window['go']['app']['App']['RestartApp']();
}
export function ResumeMeeting() { export function ResumeMeeting() {
return window['go']['app']['App']['ResumeMeeting'](); return window['go']['app']['App']['ResumeMeeting']();
} }
export function SaveWindowPosition() {
return window['go']['app']['App']['SaveWindowPosition']();
}
export function SelectCustomSound(arg1) {
return window['go']['app']['App']['SelectCustomSound'](arg1);
}
export function SkipSpeaker() { export function SkipSpeaker() {
return window['go']['app']['App']['SkipSpeaker'](); return window['go']['app']['App']['SkipSpeaker']();
} }
@@ -90,12 +126,16 @@ export function StopMeeting() {
return window['go']['app']['App']['StopMeeting'](); return window['go']['app']['App']['StopMeeting']();
} }
export function UpdateMeeting(arg1, arg2) { export function SwitchToSpeaker(arg1) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2); return window['go']['app']['App']['SwitchToSpeaker'](arg1);
} }
export function UpdateParticipant(arg1, arg2, arg3, arg4) { export function UpdateMeeting(arg1, arg2, arg3) {
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4); return window['go']['app']['App']['UpdateMeeting'](arg1, arg2, arg3);
}
export function UpdateParticipant(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4, arg5);
} }
export function UpdateSettings(arg1) { export function UpdateSettings(arg1) {

View File

@@ -112,6 +112,7 @@ export namespace models {
id: number; id: number;
name: string; name: string;
email?: string; email?: string;
jiraFilter?: string;
timeLimit: number; timeLimit: number;
order: number; order: number;
active: boolean; active: boolean;
@@ -129,6 +130,7 @@ export namespace models {
this.id = source["id"]; this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.email = source["email"]; this.email = source["email"];
this.jiraFilter = source["jiraFilter"];
this.timeLimit = source["timeLimit"]; this.timeLimit = source["timeLimit"];
this.order = source["order"]; this.order = source["order"];
this.active = source["active"]; this.active = source["active"];
@@ -253,6 +255,7 @@ export namespace models {
export class Meeting { export class Meeting {
id: number; id: number;
name: string; name: string;
jiraUrl?: string;
timeLimit: number; timeLimit: number;
sessions?: MeetingSession[]; sessions?: MeetingSession[];
// Go type: time // Go type: time
@@ -268,6 +271,7 @@ export namespace models {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"]; this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.jiraUrl = source["jiraUrl"];
this.timeLimit = source["timeLimit"]; this.timeLimit = source["timeLimit"];
this.sessions = this.convertValues(source["sessions"], MeetingSession); this.sessions = this.convertValues(source["sessions"], MeetingSession);
this.createdAt = this.convertValues(source["createdAt"], null); this.createdAt = this.convertValues(source["createdAt"], null);
@@ -327,6 +331,9 @@ export namespace models {
theme: string; theme: string;
windowWidth: number; windowWidth: number;
windowFullHeight: boolean; windowFullHeight: boolean;
windowX: number;
windowY: number;
defaultJiraUrl: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Settings(source); return new Settings(source);
@@ -345,12 +352,16 @@ export namespace models {
this.theme = source["theme"]; this.theme = source["theme"];
this.windowWidth = source["windowWidth"]; this.windowWidth = source["windowWidth"];
this.windowFullHeight = source["windowFullHeight"]; this.windowFullHeight = source["windowFullHeight"];
this.windowX = source["windowX"];
this.windowY = source["windowY"];
this.defaultJiraUrl = source["defaultJiraUrl"];
} }
} }
export class SpeakerInfo { export class SpeakerInfo {
id: number; id: number;
name: string; name: string;
timeLimit: number; timeLimit: number;
timeSpent: number;
order: number; order: number;
status: string; status: string;
@@ -363,6 +374,7 @@ export namespace models {
this.id = source["id"]; this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.timeLimit = source["timeLimit"]; this.timeLimit = source["timeLimit"];
this.timeSpent = source["timeSpent"];
this.order = source["order"]; this.order = source["order"];
this.status = source["status"]; this.status = source["status"];
} }
@@ -432,3 +444,32 @@ export namespace models {
} }
export namespace updater {
export class UpdateInfo {
available: boolean;
currentVersion: string;
latestVersion: string;
releaseNotes: string;
downloadURL: string;
downloadSize: number;
isRebuild: boolean;
static createFrom(source: any = {}) {
return new UpdateInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.currentVersion = source["currentVersion"];
this.latestVersion = source["latestVersion"];
this.releaseNotes = source["releaseNotes"];
this.downloadURL = source["downloadURL"];
this.downloadSize = source["downloadSize"];
this.isRebuild = source["isRebuild"];
}
}
}

View File

@@ -9,36 +9,61 @@ import (
"time" "time"
"daily-timer/internal/models" "daily-timer/internal/models"
"daily-timer/internal/relay"
"daily-timer/internal/services/updater"
"daily-timer/internal/storage" "daily-timer/internal/storage"
"daily-timer/internal/timer" "daily-timer/internal/timer"
"daily-timer/internal/version"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
type App struct { type App struct {
ctx context.Context ctx context.Context
store *storage.Storage store *storage.Storage
timer *timer.Timer timer *timer.Timer
session *models.MeetingSession session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string
jiraBaseURL string
relay *relay.Server
updater *updater.Updater
} }
func New(store *storage.Storage) *App { func New(store *storage.Storage) *App {
return &App{ return &App{
store: store, store: store,
currentLogs: make(map[uint]*models.ParticipantLog), currentLogs: make(map[uint]*models.ParticipantLog),
relay: relay.New(),
updater: updater.New(),
} }
} }
func (a *App) Startup(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.relay.Start()
}
func (a *App) OpenBrowserURL(url string) {
if url != "" {
runtime.BrowserOpenURL(a.ctx, url)
}
} }
func (a *App) OnDomReady(ctx context.Context) { func (a *App) OnDomReady(ctx context.Context) {
// Restore saved window position
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
if settings.WindowX >= 0 && settings.WindowY >= 0 {
runtime.WindowSetPosition(ctx, settings.WindowX, settings.WindowY)
}
}
runtime.WindowShow(ctx) runtime.WindowShow(ctx)
} }
func (a *App) Shutdown(ctx context.Context) { func (a *App) Shutdown(ctx context.Context) {
// Save window position before closing
a.saveWindowPosition()
if a.timer != nil { if a.timer != nil {
a.timer.Close() a.timer.Close()
} }
@@ -47,22 +72,42 @@ func (a *App) Shutdown(ctx context.Context) {
} }
} }
func (a *App) saveWindowPosition() {
if a.ctx == nil {
return
}
x, y := runtime.WindowGetPosition(a.ctx)
if x >= 0 && y >= 0 {
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
settings.WindowX = x
settings.WindowY = y
_ = a.store.UpdateSettings(settings)
}
}
}
// SaveWindowPosition saves current window position (can be called from frontend)
func (a *App) SaveWindowPosition() {
a.saveWindowPosition()
}
// Participants // Participants
func (a *App) GetParticipants() ([]models.Participant, error) { func (a *App) GetParticipants() ([]models.Participant, error) {
return a.store.GetParticipants() return a.store.GetParticipants()
} }
func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.Participant, error) { func (a *App) AddParticipant(name string, email string, timeLimit int, jiraFilter string) (*models.Participant, error) {
participants, _ := a.store.GetAllParticipants() participants, _ := a.store.GetAllParticipants()
order := len(participants) order := len(participants)
p := &models.Participant{ p := &models.Participant{
Name: name, Name: name,
Email: email, Email: email,
TimeLimit: timeLimit, JiraFilter: jiraFilter,
Order: order, TimeLimit: timeLimit,
Active: true, Order: order,
Active: true,
} }
if err := a.store.CreateParticipant(p); err != nil { if err := a.store.CreateParticipant(p); err != nil {
@@ -71,12 +116,13 @@ func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.
return p, nil return p, nil
} }
func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int) error { func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int, jiraFilter string) error {
p := &models.Participant{ p := &models.Participant{
ID: id, ID: id,
Name: name, Name: name,
Email: email, Email: email,
TimeLimit: timeLimit, JiraFilter: jiraFilter,
TimeLimit: timeLimit,
} }
return a.store.UpdateParticipant(p) return a.store.UpdateParticipant(p)
} }
@@ -95,13 +141,14 @@ func (a *App) GetMeeting() (*models.Meeting, error) {
return a.store.GetMeeting() return a.store.GetMeeting()
} }
func (a *App) UpdateMeeting(name string, timeLimit int) error { func (a *App) UpdateMeeting(name string, timeLimit int, jiraURL string) error {
meeting, err := a.store.GetMeeting() meeting, err := a.store.GetMeeting()
if err != nil { if err != nil {
return err return err
} }
meeting.Name = name meeting.Name = name
meeting.TimeLimit = timeLimit meeting.TimeLimit = timeLimit
meeting.JiraURL = jiraURL
return a.store.UpdateMeeting(meeting) return a.store.UpdateMeeting(meeting)
} }
@@ -149,12 +196,39 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
} }
} }
// Use meeting-specific URL, fall back to default from settings
jiraURL := meeting.JiraURL
if jiraURL == "" && settings != nil {
jiraURL = settings.DefaultJiraUrl
}
a.jiraBaseURL = jiraURL
a.participantURLs = make(map[uint]string)
if jiraURL != "" {
for _, p := range participants {
url := jiraURL
if p.JiraFilter != "" {
url = jiraURL + "&quickFilter=" + p.JiraFilter
}
a.participantURLs[p.ID] = url
}
}
if jiraURL != "" {
a.relay.Broadcast(jiraURL)
go runtime.BrowserOpenURL(a.ctx, fmt.Sprintf("http://127.0.0.1:%d/", relay.Port))
}
a.timer = timer.New(meeting.TimeLimit, warningThreshold) a.timer = timer.New(meeting.TimeLimit, warningThreshold)
a.timer.SetQueue(queue) a.timer.SetQueue(queue)
a.currentLogs = make(map[uint]*models.ParticipantLog) a.currentLogs = make(map[uint]*models.ParticipantLog)
go a.handleTimerEvents() go a.handleTimerEvents()
a.timer.Start() a.timer.Start()
if jiraURL != "" {
go runtime.BrowserOpenURL(a.ctx, jiraURL)
}
return nil return nil
} }
@@ -173,15 +247,46 @@ func (a *App) handleTimerEvents() {
runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State) runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
case timer.EventSpeakerChanged: case timer.EventSpeakerChanged:
a.saveSpeakerLog(event.State) a.saveSpeakerLog(event.State)
if url, ok := a.participantURLs[event.State.CurrentSpeakerID]; ok && url != "" {
a.relay.Broadcast(url)
runtime.EventsEmit(a.ctx, "jira:open", url)
} else if a.jiraBaseURL != "" {
a.relay.Broadcast(a.jiraBaseURL)
runtime.EventsEmit(a.ctx, "jira:open", a.jiraBaseURL)
}
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State) runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
case timer.EventMeetingEnded: case timer.EventMeetingEnded:
a.saveSpeakerLog(event.State) a.finalizeSpeakerLogs(event.State)
a.endMeetingSession(event.State) a.endMeetingSession(event.State)
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State) runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
} }
} }
} }
func (a *App) finalizeSpeakerLogs(state models.TimerState) {
if a.session == nil {
return
}
// Only finalize existing logs, don't create new ones
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)
}
}
}
func (a *App) saveSpeakerLog(state models.TimerState) { func (a *App) saveSpeakerLog(state models.TimerState) {
if a.session == nil { if a.session == nil {
return return
@@ -243,6 +348,12 @@ func (a *App) RemoveFromQueue(speakerID uint) {
} }
} }
func (a *App) SwitchToSpeaker(speakerID uint) {
if a.timer != nil {
a.timer.SwitchToSpeaker(speakerID)
}
}
func (a *App) PauseMeeting() { func (a *App) PauseMeeting() {
if a.timer != nil { if a.timer != nil {
a.timer.Pause() a.timer.Pause()
@@ -479,3 +590,130 @@ func (a *App) GetSoundsDir() string {
_ = os.MkdirAll(soundsDir, 0755) _ = os.MkdirAll(soundsDir, 0755)
return soundsDir return soundsDir
} }
// Updates
func (a *App) GetVersion() string {
return version.Version
}
func (a *App) CheckForUpdates() (*updater.UpdateInfo, error) {
return a.updater.CheckForUpdates()
}
func (a *App) DownloadAndInstallUpdate() error {
err := a.updater.DownloadAndInstall(func(progress float64) {
runtime.EventsEmit(a.ctx, "update:progress", progress)
})
if err != nil {
return err
}
runtime.EventsEmit(a.ctx, "update:complete", true)
return nil
}
func (a *App) RestartApp() error {
return a.updater.RestartApp()
}
// Sound Management
func (a *App) getSoundsDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
if err := os.MkdirAll(soundsDir, 0755); err != nil {
return "", err
}
return soundsDir, nil
}
func (a *App) SelectCustomSound(soundType string) (string, error) {
// Validate sound type
validTypes := map[string]bool{"warning": true, "timeup": true, "meeting_end": true}
if !validTypes[soundType] {
return "", fmt.Errorf("invalid sound type: %s", soundType)
}
// Open file dialog
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Sound File",
Filters: []runtime.FileFilter{
{DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.m4a;*.ogg"},
},
})
if err != nil {
return "", err
}
if selection == "" {
return "", nil // User cancelled
}
// Get sounds directory
soundsDir, err := a.getSoundsDir()
if err != nil {
return "", err
}
// Determine destination filename
ext := filepath.Ext(selection)
destPath := filepath.Join(soundsDir, soundType+ext)
// Copy file
src, err := os.Open(selection)
if err != nil {
return "", err
}
defer func() { _ = src.Close() }()
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() { _ = dst.Close() }()
if _, err := dst.ReadFrom(src); err != nil {
return "", err
}
return destPath, nil
}
func (a *App) GetCustomSoundPath(soundType string) string {
soundsDir, err := a.getSoundsDir()
if err != nil {
return ""
}
// Check for common audio extensions
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func (a *App) ClearCustomSound(soundType string) error {
soundsDir, err := a.getSoundsDir()
if err != nil {
return err
}
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return err
}
}
}
return nil
}

View File

@@ -5,19 +5,21 @@ import (
) )
type Participant struct { type Participant struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds JiraFilter string `json:"jiraFilter,omitempty"`
Order int `json:"order" gorm:"default:0"` TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
Active bool `json:"active" gorm:"default:true"` Order int `json:"order" gorm:"default:0"`
CreatedAt time.Time `json:"createdAt" tsType:"string"` Active bool `json:"active" gorm:"default:true"`
UpdatedAt time.Time `json:"updatedAt" tsType:"string"` CreatedAt time.Time `json:"createdAt" tsType:"string"`
UpdatedAt time.Time `json:"updatedAt" tsType:"string"`
} }
type Meeting struct { type Meeting struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null;default:Daily Standup"` Name string `json:"name" gorm:"not null;default:Daily Standup"`
JiraURL string `json:"jiraUrl,omitempty"`
TimeLimit int `json:"timeLimit" gorm:"default:3600"` // total meeting limit in seconds (1 hour) TimeLimit int `json:"timeLimit" gorm:"default:3600"` // total meeting limit in seconds (1 hour)
Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"` Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"`
CreatedAt time.Time `json:"createdAt" tsType:"string"` CreatedAt time.Time `json:"createdAt" tsType:"string"`
@@ -67,6 +69,9 @@ type Settings struct {
SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"` SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
Theme string `json:"theme" gorm:"default:dark"` Theme string `json:"theme" gorm:"default:dark"`
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480 WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
WindowX int `json:"windowX" gorm:"default:-1"` // -1 = not set (center)
WindowY int `json:"windowY" gorm:"default:-1"` // -1 = not set (center)
DefaultJiraUrl string `json:"defaultJiraUrl" gorm:"default:''"`
} }

View File

@@ -33,6 +33,7 @@ type SpeakerInfo struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
TimeLimit int `json:"timeLimit"` TimeLimit int `json:"timeLimit"`
TimeSpent int `json:"timeSpent"`
Order int `json:"order"` Order int `json:"order"`
Status SpeakerStatus `json:"status"` Status SpeakerStatus `json:"status"`
} }

View File

@@ -0,0 +1,428 @@
package updater
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"daily-timer/internal/version"
)
const (
GiteaAPIURL = "https://git.movida.biz/api/v1/repos/bell/daily-timer/releases/latest"
AppName = "Daily Timer.app"
InstallPath = "/Applications"
DownloadPrefix = "Daily-Timer-"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
}
type Updater struct {
latestRelease *Release
downloadURL string
}
func New() *Updater {
return &Updater{}
}
func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(GiteaAPIURL)
if err != nil {
return nil, fmt.Errorf("failed to check for updates: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to parse release info: %w", err)
}
u.latestRelease = &release
// Find macOS arm64 asset
var downloadAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".zip") {
downloadAsset = &release.Assets[i]
break
}
}
if downloadAsset == nil {
return nil, fmt.Errorf("no macOS arm64 asset found")
}
u.downloadURL = downloadAsset.BrowserDownloadURL
// Find checksum asset
var checksumAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".sha256") {
checksumAsset = &release.Assets[i]
break
}
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "v")
isNewer := isNewerVersion(latestVersion, currentVersion)
isRebuild := false
// Check if same version but different checksum (rebuild)
if !isNewer && checksumAsset != nil {
remoteChecksum, err := u.downloadChecksum(checksumAsset.BrowserDownloadURL)
if err == nil {
localChecksum, err := u.calculateBinaryChecksum()
if err == nil && remoteChecksum != localChecksum {
isRebuild = true
}
}
}
info := &UpdateInfo{
Available: isNewer || isRebuild,
CurrentVersion: version.Version,
LatestVersion: release.TagName,
ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
}
return info, nil
}
func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
if u.downloadURL == "" {
return fmt.Errorf("no download URL available, run CheckForUpdates first")
}
// Create temp directory
tmpDir, err := os.MkdirTemp("", "daily-timer-update-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
zipPath := filepath.Join(tmpDir, "update.zip")
// Download ZIP
if err := u.downloadFile(zipPath, progressCallback); err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
// Extract ZIP
extractPath := filepath.Join(tmpDir, "extracted")
if err := u.extractZip(zipPath, extractPath); err != nil {
return fmt.Errorf("failed to extract update: %w", err)
}
// Find .app in extracted folder
appPath := filepath.Join(extractPath, AppName)
if _, err := os.Stat(appPath); os.IsNotExist(err) {
return fmt.Errorf("app not found in update package")
}
// Remove old app from /Applications
destPath := filepath.Join(InstallPath, AppName)
if _, err := os.Stat(destPath); err == nil {
if err := os.RemoveAll(destPath); err != nil {
return fmt.Errorf("failed to remove old app: %w", err)
}
}
// Copy new app to /Applications
if err := u.copyDir(appPath, destPath); err != nil {
return fmt.Errorf("failed to install update: %w", err)
}
// Remove quarantine attribute
cmd := exec.Command("xattr", "-cr", destPath)
if err := cmd.Run(); err != nil {
fmt.Printf("Warning: failed to remove quarantine: %v\n", err)
}
return nil
}
func (u *Updater) RestartApp() error {
destPath := filepath.Join(InstallPath, AppName)
// Use shell to launch new app after this process exits
// The sleep ensures the current app has time to exit
script := fmt.Sprintf(`sleep 1 && open "%s"`, destPath)
cmd := exec.Command("sh", "-c", script)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
// Detach the process so it continues after we exit
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch updated app: %w", err)
}
// Release the process so it doesn't become a zombie
go func() {
_ = cmd.Wait()
}()
// Give the shell time to start
time.Sleep(100 * time.Millisecond)
// Exit current app
os.Exit(0)
return nil
}
func (u *Updater) downloadFile(destPath string, progressCallback func(float64)) error {
resp, err := http.Get(u.downloadURL)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
out, err := os.Create(destPath)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
totalSize := resp.ContentLength
var downloaded int64 = 0
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if progressCallback != nil && totalSize > 0 {
progressCallback(float64(downloaded) / float64(totalSize))
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func (u *Updater) extractZip(zipPath, destPath string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer func() { _ = r.Close() }()
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
for _, f := range r.File {
fpath := filepath.Join(destPath, f.Name)
// Prevent ZipSlip vulnerability
if !strings.HasPrefix(fpath, filepath.Clean(destPath)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", fpath)
}
if f.FileInfo().IsDir() {
_ = os.MkdirAll(fpath, f.Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
_ = outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
_ = outFile.Close()
_ = rc.Close()
if err != nil {
return err
}
}
return nil
}
func (u *Updater) copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
// Handle symlinks
if info.Mode()&os.ModeSymlink != 0 {
link, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(link, dstPath)
}
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = srcFile.Close() }()
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer func() { _ = dstFile.Close() }()
_, err = io.Copy(dstFile, srcFile)
return err
})
}
// downloadChecksum fetches the remote SHA256 checksum file
func (u *Updater) downloadChecksum(url string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download checksum: status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// calculateBinaryChecksum calculates SHA256 of the current running binary
func (u *Updater) calculateBinaryChecksum() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
// Resolve symlinks to get actual binary path
execPath, err = filepath.EvalSymlinks(execPath)
if err != nil {
return "", err
}
file, err := os.Open(execPath)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
// isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
func isNewerVersion(latest, current string) bool {
if current == "dev" || current == "unknown" {
return true
}
latest = strings.TrimPrefix(latest, "v")
current = strings.TrimPrefix(current, "v")
// Handle dirty versions (e.g., "0.1.0-dirty" or "0.1.0-3-g1234567")
if strings.Contains(current, "-") {
parts := strings.Split(current, "-")
current = parts[0]
}
latestParts := strings.Split(latest, ".")
currentParts := strings.Split(current, ".")
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
var l, c int
_, _ = fmt.Sscanf(latestParts[i], "%d", &l)
_, _ = fmt.Sscanf(currentParts[i], "%d", &c)
if l > c {
return true
} else if l < c {
return false
}
}
return len(latestParts) > len(currentParts)
}

View File

@@ -79,6 +79,9 @@ func (s *Storage) ensureDefaults() error {
// Migrate old default value to new default (60 min instead of 15) // Migrate old default value to new default (60 min instead of 15)
s.db.Model(&settings).Update("default_meeting_time", 3600) s.db.Model(&settings).Update("default_meeting_time", 3600)
} }
if settings.DefaultJiraUrl == "" {
s.db.Model(&settings).Update("default_jira_url", "https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN")
}
var meeting models.Meeting var meeting models.Meeting
result = s.db.First(&meeting) result = s.db.First(&meeting)
@@ -117,9 +120,10 @@ func (s *Storage) CreateParticipant(p *models.Participant) error {
func (s *Storage) UpdateParticipant(p *models.Participant) error { func (s *Storage) UpdateParticipant(p *models.Participant) error {
return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{ return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
"name": p.Name, "name": p.Name,
"email": p.Email, "email": p.Email,
"time_limit": p.TimeLimit, "jira_filter": p.JiraFilter,
"time_limit": p.TimeLimit,
}).Error }).Error
} }

View File

@@ -21,7 +21,7 @@ const (
) )
type Event struct { type Event struct {
Type EventType `json:"type"` Type EventType `json:"type"`
State models.TimerState `json:"state"` State models.TimerState `json:"state"`
} }
@@ -31,29 +31,29 @@ type Timer struct {
running bool running bool
paused bool paused bool
meetingStartTime time.Time meetingStartTime time.Time
meetingElapsed time.Duration meetingElapsed time.Duration
meetingLimit time.Duration meetingLimit time.Duration
speakerStartTime time.Time speakerStartTime time.Time
speakerElapsed time.Duration speakerElapsed time.Duration
speakerLimit time.Duration speakerLimit time.Duration
currentSpeakerID uint currentSpeakerID uint
currentSpeaker string currentSpeaker string
speakingOrder int speakingOrder int
queue []models.QueuedSpeaker queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo allSpeakers []models.SpeakerInfo
warningThreshold time.Duration warningThreshold time.Duration
speakerWarned bool speakerWarned bool
speakerTimeUpEmitted bool speakerTimeUpEmitted bool
meetingWarned bool meetingWarned bool
eventCh chan Event eventCh chan Event
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
pausedAt time.Time pausedAt time.Time
} }
func New(meetingLimitSec, warningThresholdSec int) *Timer { func New(meetingLimitSec, warningThresholdSec int) *Timer {
@@ -75,7 +75,7 @@ func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.queue = speakers t.queue = speakers
// Initialize allSpeakers with pending status // Initialize allSpeakers with pending status
t.allSpeakers = make([]models.SpeakerInfo, len(speakers)) t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
for i, s := range speakers { for i, s := range speakers {
@@ -105,25 +105,24 @@ func (t *Timer) Start() {
t.speakerWarned = false t.speakerWarned = false
t.meetingWarned = false t.meetingWarned = false
if len(t.queue) > 0 {
t.startNextSpeaker(now)
}
t.mu.Unlock() t.mu.Unlock()
// Не активируем участника автоматически!
go t.tick() go t.tick()
} }
func (t *Timer) startNextSpeaker(now time.Time) { func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
if len(t.queue) == 0 { if len(t.queue) == 0 {
return return
} }
// Mark previous speaker as done (only if they were speaking, not skipped) // Mark previous speaker as done and save their time spent
if t.currentSpeakerID != 0 { if t.currentSpeakerID != 0 {
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers { for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID { if t.allSpeakers[i].ID == t.currentSpeakerID {
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking { if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
t.allSpeakers[i].Status = models.SpeakerStatusDone t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
} }
break break
} }
@@ -135,8 +134,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
t.currentSpeakerID = speaker.ID t.currentSpeakerID = speaker.ID
t.currentSpeaker = speaker.Name t.currentSpeaker = speaker.Name
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
t.speakerStartTime = now t.speakerStartTime = now.Add(-offset)
t.speakerElapsed = 0 t.speakerElapsed = offset
t.speakingOrder++ t.speakingOrder++
t.speakerWarned = false t.speakerWarned = false
t.speakerTimeUpEmitted = false t.speakerTimeUpEmitted = false
@@ -191,7 +190,7 @@ func (t *Timer) NextSpeaker() {
var eventType EventType var eventType EventType
if len(t.queue) > 0 { if len(t.queue) > 0 {
t.startNextSpeaker(now) t.startNextSpeaker(now, 0)
eventType = EventSpeakerChanged eventType = EventSpeakerChanged
} else { } else {
t.running = false t.running = false
@@ -225,11 +224,14 @@ func (t *Timer) SkipSpeaker() {
now := time.Now() now := time.Now()
if len(t.queue) > 1 { if len(t.queue) > 1 {
t.startNextSpeaker(now) t.startNextSpeaker(now, 0)
t.mu.Unlock() t.mu.Unlock()
t.emit(EventSpeakerChanged) t.emit(EventSpeakerChanged)
} else { } else {
// Only skipped speaker left - they need to speak now
t.startNextSpeaker(now, 0)
t.mu.Unlock() t.mu.Unlock()
t.emit(EventSpeakerChanged)
} }
} }
@@ -246,7 +248,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
return return
} }
// Remove from queue // Find speaker info before removing
var speakerInfo models.QueuedSpeaker
for _, s := range t.queue {
if s.ID == speakerID {
speakerInfo = s
break
}
}
// Remove from current position in queue
for i, s := range t.queue { for i, s := range t.queue {
if s.ID == speakerID { if s.ID == speakerID {
t.queue = append(t.queue[:i], t.queue[i+1:]...) t.queue = append(t.queue[:i], t.queue[i+1:]...)
@@ -254,11 +265,124 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
} }
} }
// Add to end of queue so they can speak later
if speakerInfo.ID != 0 {
t.queue = append(t.queue, speakerInfo)
}
// Mark as skipped in allSpeakers and move to end // Mark as skipped in allSpeakers and move to end
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped) t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
t.moveSpeakerToEnd(speakerID) t.moveSpeakerToEnd(speakerID)
} }
// SwitchToSpeaker moves the specified speaker to front of queue and starts them
// If speaker is already done, resumes their timer from accumulated time
func (t *Timer) SwitchToSpeaker(speakerID uint) {
t.mu.Lock()
if !t.running {
t.mu.Unlock()
return
}
// First, find speaker in allSpeakers to get their info and status
var speakerInfo *models.SpeakerInfo
var speakerInfoIdx int
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == speakerID {
speakerInfo = &t.allSpeakers[i]
speakerInfoIdx = i
break
}
}
if speakerInfo == nil {
t.mu.Unlock()
return
}
// Don't switch to currently speaking speaker
if speakerInfo.Status == models.SpeakerStatusSpeaking {
t.mu.Unlock()
return
}
// Calculate offset for resuming (0 for pending/skipped, timeSpent for done)
var offset time.Duration
if speakerInfo.Status == models.SpeakerStatusDone {
offset = time.Duration(speakerInfo.TimeSpent) * time.Second
}
// Save current speaker time
now := time.Now()
if t.currentSpeakerID != 0 {
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
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
t.allSpeakers[i].TimeSpent = timeSpent
}
break
}
}
}
// Find speaker in queue (pending/skipped) or create new entry (done)
foundIdx := -1
for i, s := range t.queue {
if s.ID == speakerID {
foundIdx = i
break
}
}
// Create QueuedSpeaker from SpeakerInfo
queuedSpeaker := models.QueuedSpeaker{
ID: speakerInfo.ID,
Name: speakerInfo.Name,
TimeLimit: speakerInfo.TimeLimit,
Order: speakerInfo.Order,
}
if foundIdx >= 0 {
// Remove from current position in queue
t.queue = append(t.queue[:foundIdx], t.queue[foundIdx+1:]...)
}
// Insert at front of queue
t.queue = append([]models.QueuedSpeaker{queuedSpeaker}, t.queue...)
// Move the selected speaker in allSpeakers to position after last done/speaking
insertPos := 0
for i, s := range t.allSpeakers {
if s.Status == models.SpeakerStatusDone || s.Status == models.SpeakerStatusSpeaking {
insertPos = i + 1
}
}
if speakerInfoIdx >= 0 && speakerInfoIdx != insertPos {
// Save speaker info before removing
savedInfo := *speakerInfo
// Remove from current position
t.allSpeakers = append(t.allSpeakers[:speakerInfoIdx], t.allSpeakers[speakerInfoIdx+1:]...)
// Adjust insert position if needed
if speakerInfoIdx < insertPos {
insertPos--
}
// Insert at new position
t.allSpeakers = append(t.allSpeakers[:insertPos], append([]models.SpeakerInfo{savedInfo}, t.allSpeakers[insertPos:]...)...)
// Update order numbers
for i := range t.allSpeakers {
t.allSpeakers[i].Order = i + 1
}
}
// Start this speaker with offset (0 for new speakers, accumulated time for done)
t.startNextSpeaker(now, offset)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
}
func (t *Timer) Pause() { func (t *Timer) Pause() {
t.mu.Lock() t.mu.Lock()
@@ -299,9 +423,17 @@ func (t *Timer) Stop() {
t.mu.Unlock() t.mu.Unlock()
return return
} }
// Mark current speaker as done before stopping // Mark current speaker as done and save their time spent
if t.currentSpeakerID != 0 { if t.currentSpeakerID != 0 {
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone) now := time.Now()
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
for i := range t.allSpeakers {
if t.allSpeakers[i].ID == t.currentSpeakerID {
t.allSpeakers[i].Status = models.SpeakerStatusDone
t.allSpeakers[i].TimeSpent = timeSpent
break
}
}
} }
t.running = false t.running = false
t.paused = false t.paused = false
@@ -321,7 +453,9 @@ func (t *Timer) buildState() models.TimerState {
if t.running && !t.paused { if t.running && !t.paused {
now := time.Now() now := time.Now()
speakerElapsed = now.Sub(t.speakerStartTime) if t.currentSpeakerID != 0 {
speakerElapsed = now.Sub(t.speakerStartTime)
}
meetingElapsed = now.Sub(t.meetingStartTime) meetingElapsed = now.Sub(t.meetingStartTime)
} }
@@ -332,7 +466,7 @@ func (t *Timer) buildState() models.TimerState {
// Copy allSpeakers to avoid data race and calculate total speakers time // Copy allSpeakers to avoid data race and calculate total speakers time
allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers)) allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
copy(allSpeakers, t.allSpeakers) copy(allSpeakers, t.allSpeakers)
totalSpeakersTime := 0 totalSpeakersTime := 0
for _, s := range t.allSpeakers { for _, s := range t.allSpeakers {
totalSpeakersTime += s.TimeLimit totalSpeakersTime += s.TimeLimit
@@ -379,9 +513,15 @@ func (t *Timer) tick() {
} }
now := time.Now() now := time.Now()
speakerElapsed := now.Sub(t.speakerStartTime)
meetingElapsed := now.Sub(t.meetingStartTime) meetingElapsed := now.Sub(t.meetingStartTime)
if t.currentSpeakerID == 0 {
t.mu.Unlock()
t.emit(EventTick)
continue
}
speakerElapsed := now.Sub(t.speakerStartTime)
remaining := t.speakerLimit - speakerElapsed remaining := t.speakerLimit - speakerElapsed
if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 { if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
t.speakerWarned = true t.speakerWarned = true

View File

@@ -0,0 +1,10 @@
package version
// Version is set at build time via ldflags
var Version = "dev"
// GitCommit is set at build time via ldflags
var GitCommit = "unknown"
// BuildTime is set at build time via ldflags
var BuildTime = "unknown"

View File

@@ -13,7 +13,7 @@
"info": { "info": {
"companyName": "Movida.Biz", "companyName": "Movida.Biz",
"productName": "Daily Timer", "productName": "Daily Timer",
"productVersion": "1.0.0", "productVersion": "0.1.0",
"comments": "Meeting timer with participant time tracking", "comments": "Meeting timer with participant time tracking",
"copyright": "Copyright © 2026 Movida.Biz" "copyright": "Copyright © 2026 Movida.Biz"
} }