30 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
20 changed files with 1652 additions and 176 deletions

View File

@@ -64,8 +64,10 @@ release: lint
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true @xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
@rm -rf dist && 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: lint release-all: lint
@@ -79,7 +81,8 @@ release-all: lint
@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 \
@@ -89,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 \
@@ -99,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,6 +1,6 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp } 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, EventsOn, EventsOff } 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'
@@ -12,10 +12,21 @@
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 // Update state
let currentVersion = 'dev' let currentVersion = 'dev'
let updateInfo = null let updateInfo = null
@@ -51,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)
@@ -74,8 +91,63 @@
} }
} }
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 // Load version and check for updates
try { try {
@@ -94,9 +166,29 @@
updateComplete = true 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 () => { return () => {
EventsOff('update:progress') EventsOff('update:progress')
EventsOff('update:complete') EventsOff('update:complete')
document.removeEventListener('click', warmUpAudio)
} }
}) })
@@ -147,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
} }
@@ -162,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
@@ -223,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>
@@ -247,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>
@@ -309,7 +476,11 @@
</div> </div>
{:else if updateInfo?.available} {:else if updateInfo?.available}
<div class="update-status available"> <div class="update-status available">
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong> {#if updateInfo.isRebuild}
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{:else}
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
{/if}
</div> </div>
<button class="update-btn primary" on:click={downloadAndInstall}> <button class="update-btn primary" on:click={downloadAndInstall}>
{$t('updates.downloadAndInstall')} {$t('updates.downloadAndInstall')}
@@ -383,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;
@@ -466,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;
@@ -494,6 +660,84 @@
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 */
.updates-section { .updates-section {
border: 1px solid #3d4f61; border: 1px solid #3d4f61;
@@ -615,4 +859,79 @@
background: #3b7dc9; background: #3b7dc9;
border-color: #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: 'Светлая',
@@ -114,6 +122,7 @@ export const translations = {
currentVersion: 'Текущая версия', currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...', checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление', updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия', upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить', downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...', downloading: 'Загрузка...',
@@ -133,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: 'Всего собраний',
@@ -190,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
@@ -209,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',
@@ -261,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',
@@ -281,6 +301,7 @@ export const translations = {
currentVersion: 'Current version', currentVersion: 'Current version',
checkingForUpdates: 'Checking for updates...', checkingForUpdates: 'Checking for updates...',
updateAvailable: 'Update available', updateAvailable: 'Update available',
rebuildAvailable: 'Rebuild available',
upToDate: 'You have the latest version', upToDate: 'You have the latest version',
downloadAndInstall: 'Download and install', downloadAndInstall: 'Download and install',
downloading: 'Downloading...', downloading: 'Downloading...',
@@ -300,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

@@ -3,10 +3,12 @@
import {models} from '../models'; import {models} from '../models';
import {updater} 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 CheckForUpdates():Promise<updater.UpdateInfo>;
export function ClearCustomSound(arg1:string):Promise<void>;
export function DeleteAllSessions():Promise<void>; export function DeleteAllSessions():Promise<void>;
export function DeleteParticipant(arg1:number):Promise<void>; export function DeleteParticipant(arg1:number):Promise<void>;
@@ -19,6 +21,8 @@ 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>>;
@@ -39,6 +43,8 @@ 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>;
@@ -49,14 +55,20 @@ 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,14 +2,18 @@
// 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() { export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates'](); return window['go']['app']['App']['CheckForUpdates']();
} }
export function ClearCustomSound(arg1) {
return window['go']['app']['App']['ClearCustomSound'](arg1);
}
export function DeleteAllSessions() { export function DeleteAllSessions() {
return window['go']['app']['App']['DeleteAllSessions'](); return window['go']['app']['App']['DeleteAllSessions']();
} }
@@ -34,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']();
} }
@@ -74,6 +82,10 @@ 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']();
} }
@@ -94,6 +106,14 @@ 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']();
} }
@@ -106,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"];
} }
@@ -441,6 +453,7 @@ export namespace updater {
releaseNotes: string; releaseNotes: string;
downloadURL: string; downloadURL: string;
downloadSize: number; downloadSize: number;
isRebuild: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new UpdateInfo(source); return new UpdateInfo(source);
@@ -454,6 +467,7 @@ export namespace updater {
this.releaseNotes = source["releaseNotes"]; this.releaseNotes = source["releaseNotes"];
this.downloadURL = source["downloadURL"]; this.downloadURL = source["downloadURL"];
this.downloadSize = source["downloadSize"]; this.downloadSize = source["downloadSize"];
this.isRebuild = source["isRebuild"];
} }
} }

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"daily-timer/internal/models" "daily-timer/internal/models"
"daily-timer/internal/relay"
"daily-timer/internal/services/updater" "daily-timer/internal/services/updater"
"daily-timer/internal/storage" "daily-timer/internal/storage"
"daily-timer/internal/timer" "daily-timer/internal/timer"
@@ -18,31 +19,51 @@ import (
) )
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
updater *updater.Updater 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(), 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()
} }
@@ -51,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 {
@@ -75,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)
} }
@@ -99,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)
} }
@@ -153,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
} }
@@ -177,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
@@ -247,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()
@@ -508,3 +615,105 @@ func (a *App) DownloadAndInstallUpdate() error {
func (a *App) RestartApp() error { func (a *App) RestartApp() error {
return a.updater.RestartApp() 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

@@ -2,6 +2,8 @@ package updater
import ( import (
"archive/zip" "archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -43,6 +45,7 @@ type UpdateInfo struct {
ReleaseNotes string `json:"releaseNotes"` ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"` DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"` DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
} }
type Updater struct { type Updater struct {
@@ -89,16 +92,40 @@ func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
u.downloadURL = downloadAsset.BrowserDownloadURL 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") latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "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{ info := &UpdateInfo{
Available: isNewerVersion(latestVersion, currentVersion), Available: isNewer || isRebuild,
CurrentVersion: version.Version, CurrentVersion: version.Version,
LatestVersion: release.TagName, LatestVersion: release.TagName,
ReleaseNotes: release.Body, ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL, DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size, DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
} }
return info, nil return info, nil
@@ -160,12 +187,27 @@ func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
func (u *Updater) RestartApp() error { func (u *Updater) RestartApp() error {
destPath := filepath.Join(InstallPath, AppName) destPath := filepath.Join(InstallPath, AppName)
// Launch new app // Use shell to launch new app after this process exits
cmd := exec.Command("open", destPath) // 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 { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch updated app: %w", err) 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 // Exit current app
os.Exit(0) os.Exit(0)
return nil return nil
@@ -304,6 +346,54 @@ func (u *Updater) copyDir(src, dst string) error {
}) })
} }
// 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") // isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
func isNewerVersion(latest, current string) bool { func isNewerVersion(latest, current string) bool {
if current == "dev" || current == "unknown" { if current == "dev" || current == "unknown" {

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 {
@@ -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)
} }
@@ -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