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
@rm -rf dist && mkdir -p dist
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"
@ls -lh dist/*.zip
@echo "Checksum: $$(cat dist/Daily-Timer-$(VERSION)-macos-arm64.sha256)"
@ls -lh dist/*
# Release for both architectures
release-all: lint
@@ -79,7 +81,8 @@ release-all: lint
@ls -lh dist/*.zip
# 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
@echo "Creating release $(VERSION) on Gitea..."
@RELEASE_ID=$$(curl -s -X POST \
@@ -89,7 +92,7 @@ release-upload:
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
| jq -r '.id'); \
echo "Created release ID: $$RELEASE_ID"; \
for file in dist/*.zip; do \
for file in dist/*; do \
filename=$$(basename "$$file"); \
echo "Uploading $$filename..."; \
curl -s -X POST \
@@ -99,8 +102,8 @@ release-upload:
done
@echo "Done!"
# Full release cycle: build + upload
release-publish: release release-upload
# Full release cycle: build + upload (release-upload already depends on release)
release-publish: release-upload
# Help
help:

View File

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

View File

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

View File

@@ -30,8 +30,33 @@
await StopMeeting()
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>
<svelte:window on:keydown={handleKeydown} />
<div class="controls">
<button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')}

View File

@@ -6,8 +6,9 @@
let sessions = []
let stats = null
let loading = true
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
let dateTo = new Date().toISOString().split('T')[0]
const _now = new Date()
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 showDeleteAllConfirm = false
let deletingSessionId = null
@@ -205,7 +206,15 @@
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
<span class="log-duration">{formatTime(log.duration)}</span>
<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}
<span class="overtime-icon">⚠️</span>
{/if}
@@ -463,6 +472,22 @@
.log-duration {
color: #9ca3af;
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 {

View File

@@ -1,31 +1,89 @@
<script>
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, tick } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
let listEl
let lastSpeakingOrder = 0
$: allSpeakers = timerState?.allSpeakers || []
$: 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) {
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>
<div class="participant-list">
<h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0}
<ul>
<ul bind:this={listEl}>
{#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="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
<span class="time-display">
{#if speaker.status === 'done'}
<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>
{/if}
@@ -85,6 +143,18 @@
background: #1b2636;
}
.speaker-item.clickable {
cursor: pointer;
}
.speaker-item.clickable:hover {
background: #2d3f52;
}
.speaker-item.done.clickable:hover {
background: #2a4a6f;
}
.speaker-item.skipped {
background: repeating-linear-gradient(
45deg,
@@ -129,13 +199,6 @@
white-space: nowrap;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn {
margin-left: 8px;
padding: 4px 8px;
@@ -163,4 +226,30 @@
text-align: center;
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>

View File

@@ -1,6 +1,6 @@
<script>
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 { t, locale, setLocale } from '../lib/i18n'
@@ -12,10 +12,21 @@
let saving = false
let meetingLimitMin = 15
let defaultTimeMin = 2
let defaultJiraUrl = ''
let editingDefaultJiraUrl = false
let defaultJiraUrlInput = ''
let windowWidth = 800
let windowFullHeight = true
let audioContext = null
// Custom sounds state
let customSounds = {
warning: null,
timeup: null,
meeting_end: null
}
let audioElements = {}
// Update state
let currentVersion = 'dev'
let updateInfo = null
@@ -51,11 +62,17 @@
oscillator.stop(ctx.currentTime + duration)
} catch (e) {
console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
}
}
function testSound(name) {
// If custom sound exists, play it
if (customSounds[name]) {
playCustomSound(name)
return
}
// Otherwise play default beep sounds
switch (name) {
case 'warning':
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 () => {
await loadData()
await loadCustomSounds()
// Load version and check for updates
try {
@@ -94,9 +166,29 @@
updateComplete = true
})
// Warm up AudioContext on first user interaction (for sound tests)
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
return () => {
EventsOff('update:progress')
EventsOff('update:complete')
document.removeEventListener('click', warmUpAudio)
}
})
@@ -147,6 +239,7 @@
}
if (settings) {
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
defaultJiraUrl = settings.defaultJiraUrl || 'https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN'
windowWidth = settings.windowWidth || 800
windowFullHeight = settings.windowFullHeight !== false
}
@@ -162,6 +255,7 @@
saving = true
try {
settings.defaultParticipantTime = defaultTimeMin * 60
settings.defaultJiraUrl = defaultJiraUrl.trim()
meeting.timeLimit = meetingLimitMin * 60
settings.windowWidth = Math.max(480, windowWidth)
settings.windowFullHeight = windowFullHeight
@@ -223,6 +317,29 @@
<label for="meetingLimit">{$t('settings.defaultTotalTime')}</label>
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
</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>
@@ -247,10 +364,60 @@
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div>
<div class="sound-test-buttons">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button>
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
<div class="sound-items">
<div class="sound-item">
<div class="sound-info">
<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>
</section>
@@ -309,7 +476,11 @@
</div>
{:else if updateInfo?.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>
<button class="update-btn primary" on:click={downloadAndInstall}>
{$t('updates.downloadAndInstall')}
@@ -383,7 +554,8 @@
}
input[type="text"],
input[type="number"] {
input[type="number"],
input[type="url"] {
width: 100%;
padding: 12px;
border: 1px solid #3d4f61;
@@ -466,12 +638,6 @@
color: #6b7280;
}
.sound-test-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.test-btn {
flex: 1;
padding: 10px 12px;
@@ -494,6 +660,84 @@
transform: scale(0.97);
}
/* Sound items */
.sound-items {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.sound-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 8px;
}
.sound-info {
display: flex;
align-items: center;
gap: 10px;
}
.sound-label {
font-size: 14px;
color: #e0e0e0;
}
.sound-status {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
.sound-status.custom {
background: #2a4a3a;
color: #6ee7b7;
}
.sound-status.default {
background: #3d4f61;
color: #9ca3af;
}
.sound-actions {
display: flex;
gap: 6px;
}
.sound-actions button {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #3d4f61;
border-radius: 6px;
background: #2a3a4e;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.sound-actions button:hover {
border-color: #4a90d9;
background: #3a4a5e;
color: #e0e0e0;
}
.sound-actions .clear-btn:hover {
border-color: #ef4444;
background: #4a2a2a;
color: #fca5a5;
}
/* Updates section */
.updates-section {
border: 1px solid #3d4f61;
@@ -615,4 +859,79 @@
background: #3b7dc9;
border-color: #3b7dc9;
}
.jira-display {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
transition: border-color 0.15s;
}
.jira-display:hover {
border-color: #4a90d9;
}
.jira-url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.jira-edit-icon {
flex-shrink: 0;
margin-left: 8px;
color: #9ca3af;
opacity: 0;
transition: opacity 0.15s;
}
.jira-display:hover .jira-edit-icon {
opacity: 1;
}
.jira-edit-inline {
display: flex;
gap: 8px;
align-items: center;
}
.jira-edit-inline input {
flex: 1;
padding: 12px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
box-sizing: border-box;
}
.jira-inline-save,
.jira-inline-cancel {
flex-shrink: 0;
width: 36px;
height: 42px;
border: none;
border-radius: 8px;
font-size: 18px;
cursor: pointer;
}
.jira-inline-save {
background: #22c55e;
color: white;
}
.jira-inline-cancel {
background: #ef4444;
color: white;
}
</style>

View File

@@ -1,14 +1,14 @@
<script>
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 { attendance } from '../lib/stores'
const dispatch = createEventDispatcher()
let participants = []
let meeting = null
let selectedOrder = []
let attendance = {}
let loading = true
let newName = ''
let newTimeLimitMin = 2
@@ -17,15 +17,55 @@
let editingId = null
let editName = ''
let editTimeLimitMin = 2
let editJiraFilter = ''
// Quick jira filter edit
let quickFilterEditId = null
let quickFilterInput = ''
// Meeting name editing
let editingMeetingName = false
let meetingNameInput = ''
let originalMeetingName = ''
// Meeting time editing
let editingMeetingTime = false
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 () => {
await loadData()
})
@@ -35,12 +75,11 @@
try {
participants = await GetParticipants()
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)
attendance = {}
participants.forEach(p => {
attendance[p.id] = true
})
attendance.init(participants)
} catch (e) {
console.error('Failed to load data:', e)
}
@@ -51,7 +90,7 @@
if (!newName.trim()) return
try {
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60)
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60, '')
newName = ''
await loadData()
} catch (e) {
@@ -74,19 +113,43 @@
editingId = p.id
editName = p.name
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() {
editingId = null
editName = ''
editTimeLimitMin = 2
editJiraFilter = ''
}
async function saveEdit() {
if (!editName.trim() || editingId === null) return
try {
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60)
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60, editJiraFilter.trim())
editingId = null
await loadData()
} catch (e) {
@@ -95,8 +158,7 @@
}
function toggleAttendance(id) {
attendance[id] = !attendance[id]
attendance = attendance
attendance.toggle(id)
}
// Drag and drop state
@@ -152,14 +214,15 @@
}
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) {
alert($t('setup.noParticipants'))
return
}
try {
await StartMeeting(presentIds, attendance)
await StartMeeting(presentIds, att)
dispatch('started')
} catch (e) {
console.error('Failed to start meeting:', e)
@@ -178,7 +241,8 @@
}
function startEditMeetingName() {
meetingNameInput = meeting?.name || ''
originalMeetingName = meeting?.name || ''
meetingNameInput = originalMeetingName
editingMeetingName = true
}
@@ -190,7 +254,7 @@
async function saveMeetingName() {
if (!meetingNameInput.trim()) return
try {
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600)
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600, meeting?.jiraUrl || '')
meeting = await GetMeeting()
editingMeetingName = false
} catch (e) {
@@ -199,7 +263,8 @@
}
function startEditMeetingTime() {
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60)
originalMeetingTime = Math.floor((meeting?.timeLimit || 3600) / 60)
meetingTimeInput = originalMeetingTime
editingMeetingTime = true
}
@@ -210,7 +275,7 @@
async function saveMeetingTime() {
if (meetingTimeInput < 1) return
try {
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60)
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60, meeting?.jiraUrl || '')
meeting = await GetMeeting()
editingMeetingTime = false
} 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) {
if (e.key === 'Escape') {
if (editingId !== null) cancelEdit()
if (editingMeetingName) cancelEditMeetingName()
if (editingMeetingTime) cancelEditMeetingTime()
if (editingMeetingJiraUrl) cancelEditMeetingJiraUrl()
}
}
</script>
@@ -248,11 +337,12 @@
</div>
{:else}
<!-- 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'}
<span class="edit-icon"></span>
</h1>
{/if}
<div class="meeting-info-block">
{#if editingMeetingTime}
<div class="meeting-time-edit">
<!-- svelte-ignore a11y-autofocus -->
@@ -273,13 +363,51 @@
</div>
{:else}
<!-- 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)}
<span class="edit-icon"></span>
</p>
{/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>
{#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">
<input
type="text"
@@ -314,7 +442,7 @@
{@const p = getParticipant(id)}
{#if p}
<li
class:absent={!attendance[id]}
class:absent={!$attendance[id]}
class:drag-over={dragOverId === id}
draggable="true"
on:dragstart={(e) => handleDragStart(e, id)}
@@ -329,14 +457,23 @@
<button
class="attendance-toggle"
class:present={attendance[id]}
class:present={$attendance[id]}
on:click={() => toggleAttendance(id)}
>
{attendance[id] ? '✓' : '✗'}
{$attendance[id] ? '✓' : '✗'}
</button>
<span class="name">{p.name}</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="remove" on:click={() => handleRemove(id)}>×</button>
@@ -366,6 +503,13 @@
if (e.key === 'Escape') cancelEdit()
}} />
</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">
<button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button>
<button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button>
@@ -374,9 +518,33 @@
</div>
{/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">
<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>{$t('setup.participants')}: {Object.values($attendance).filter(Boolean).length} / {participants.length}</span>
<span>{formatTime(selectedOrder.filter(id => $attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
</div>
<button class="start-btn" on:click={handleStart}>
@@ -393,11 +561,12 @@
.header {
text-align: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.header h1 {
margin: 0;
font-size: 22px;
color: #e0e0e0;
display: block;
}
@@ -469,9 +638,11 @@
.header p.editable-time {
cursor: pointer;
display: inline-flex;
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
font-size: 14px;
}
.header p.editable-time:hover {
@@ -532,6 +703,89 @@
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 {
display: flex;
gap: 6px;
@@ -680,6 +934,28 @@
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 {
padding: 4px 8px;
background: transparent;
@@ -720,6 +996,61 @@
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 {
background: #232f3e;
border-radius: 12px;
@@ -728,6 +1059,10 @@
max-width: 320px;
}
.edit-modal.quick-filter-modal {
max-width: 280px;
}
.edit-modal h3 {
margin: 0 0 16px 0;
color: #e0e0e0;

View File

@@ -1,8 +1,10 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { t } from '../lib/i18n'
import { OpenBrowserURL } from '../../wailsjs/go/app/App'
export let timerState
export let jiraUrl = ''
let currentTime = ''
let clockInterval
@@ -63,6 +65,9 @@
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
<div class="header-row">
<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-tooltip">
@@ -204,6 +209,17 @@
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 {
font-size: 14px;
color: #8899a6;

View File

@@ -23,10 +23,12 @@ export const translations = {
deselectAll: 'Снять выбор',
startMeeting: 'Начать собрание',
speakerTime: 'Время на спикера',
totalTime: 'Общее время',
totalTime: 'Общее время собрания',
minutes: 'мин',
unlimited: 'Без ограничения',
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
jiraUrl: 'Jira Kanban URL',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
},
// Timer page
@@ -42,7 +44,7 @@ export const translations = {
noSpeaker: 'Нет спикера',
noActiveMeeting: 'Собрание не запущено',
goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
readyToStart: 'Всё готово к началу',
readyToStart: 'Всё готово к началу собрания',
editParticipants: 'Редактировать участников',
noParticipants: 'Участники не добавлены',
registeredParticipants: 'Зарегистрированные участники',
@@ -94,10 +96,16 @@ export const translations = {
sound: 'Звуковые уведомления',
soundEnabled: 'Включены',
soundDisabled: 'Выключены',
testWarning: 'Предупреждение',
testTimeUp: 'Время вышло',
testMeetingEnd: 'Конец собрания',
customSound: 'свой',
defaultSound: 'стандартный',
warningTime: 'Предупреждение за',
seconds: 'сек',
defaultSpeakerTime: 'Время на спикера по умолчанию',
defaultTotalTime: 'Общее время собрания (мин)',
defaultJiraUrl: 'Jira URL Панели Kanban',
theme: 'Тема оформления',
themeDark: 'Тёмная',
themeLight: 'Светлая',
@@ -114,6 +122,7 @@ export const translations = {
currentVersion: 'Текущая версия',
checkingForUpdates: 'Проверка обновлений...',
updateAvailable: 'Доступно обновление',
rebuildAvailable: 'Доступна пересборка',
upToDate: 'У вас последняя версия',
downloadAndInstall: 'Скачать и установить',
downloading: 'Загрузка...',
@@ -133,6 +142,9 @@ export const translations = {
edit: 'Редактировать',
delete: 'Удалить',
name: 'Имя',
jiraFilter: 'Jira Kanban URL',
jiraFilterPlaceholder: 'quickFilter ID (напр. 12345)',
jiraFilterEmpty: 'Jira фильтр не задан — нажмите для настройки',
stats: 'Статистика',
avgSpeakTime: 'Среднее время выступления',
totalMeetings: 'Всего собраний',
@@ -190,10 +202,12 @@ export const translations = {
deselectAll: 'Deselect All',
startMeeting: 'Start Meeting',
speakerTime: 'Speaker Time',
totalTime: 'Total Time',
totalTime: 'Total Meeting Time',
minutes: 'min',
unlimited: 'Unlimited',
dragHint: 'drag to reorder, ✓/✗ attendance',
jiraUrl: 'Jira Kanban URL',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
},
// Timer page
@@ -209,7 +223,7 @@ export const translations = {
noSpeaker: 'No speaker',
noActiveMeeting: 'No active meeting',
goToParticipants: 'Go to Participants to add participants',
readyToStart: 'Ready to start',
readyToStart: 'Ready to start the meeting',
editParticipants: 'Edit participants',
noParticipants: 'No participants added',
registeredParticipants: 'Registered participants',
@@ -261,10 +275,16 @@ export const translations = {
sound: 'Sound Notifications',
soundEnabled: 'Enabled',
soundDisabled: 'Disabled',
testWarning: 'Warning',
testTimeUp: 'Time Up',
testMeetingEnd: 'Meeting End',
customSound: 'custom',
defaultSound: 'default',
warningTime: 'Warning before',
seconds: 'sec',
defaultSpeakerTime: 'Default Speaker Time',
defaultTotalTime: 'Total meeting time (min)',
defaultJiraUrl: 'Jira Kanban Board URL',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
@@ -281,6 +301,7 @@ export const translations = {
currentVersion: 'Current version',
checkingForUpdates: 'Checking for updates...',
updateAvailable: 'Update available',
rebuildAvailable: 'Rebuild available',
upToDate: 'You have the latest version',
downloadAndInstall: 'Download and install',
downloading: 'Downloading...',
@@ -300,6 +321,9 @@ export const translations = {
edit: 'Edit',
delete: 'Delete',
name: 'Name',
jiraFilter: 'Jira Kanban URL',
jiraFilterPlaceholder: 'quickFilter ID (e.g. 12345)',
jiraFilterEmpty: 'Jira filter not set — click to configure',
stats: 'Statistics',
avgSpeakTime: 'Avg Speaking Time',
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 {updater} from '../models';
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>;
export function AddParticipant(arg1:string,arg2:string,arg3:number,arg4:string):Promise<models.Participant>;
export function CheckForUpdates():Promise<updater.UpdateInfo>;
export function ClearCustomSound(arg1:string):Promise<void>;
export function DeleteAllSessions():Promise<void>;
export function 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 GetCustomSoundPath(arg1:string):Promise<string>;
export function GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>;
@@ -39,6 +43,8 @@ export function GetVersion():Promise<string>;
export function NextSpeaker():Promise<void>;
export function OpenBrowserURL(arg1:string):Promise<void>;
export function PauseMeeting():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 SaveWindowPosition():Promise<void>;
export function SelectCustomSound(arg1:string):Promise<string>;
export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
export function 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>;

View File

@@ -2,14 +2,18 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddParticipant(arg1, arg2, arg3) {
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3);
export function AddParticipant(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3, arg4);
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function ClearCustomSound(arg1) {
return window['go']['app']['App']['ClearCustomSound'](arg1);
}
export function DeleteAllSessions() {
return window['go']['app']['App']['DeleteAllSessions']();
}
@@ -34,6 +38,10 @@ export function 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() {
return window['go']['app']['App']['GetMeeting']();
}
@@ -74,6 +82,10 @@ export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker']();
}
export function OpenBrowserURL(arg1) {
return window['go']['app']['App']['OpenBrowserURL'](arg1);
}
export function PauseMeeting() {
return window['go']['app']['App']['PauseMeeting']();
}
@@ -94,6 +106,14 @@ export function 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() {
return window['go']['app']['App']['SkipSpeaker']();
}
@@ -106,12 +126,16 @@ export function StopMeeting() {
return window['go']['app']['App']['StopMeeting']();
}
export function UpdateMeeting(arg1, arg2) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
export function SwitchToSpeaker(arg1) {
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
}
export function UpdateParticipant(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4);
export function UpdateMeeting(arg1, arg2, arg3) {
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) {

View File

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

View File

@@ -9,6 +9,7 @@ import (
"time"
"daily-timer/internal/models"
"daily-timer/internal/relay"
"daily-timer/internal/services/updater"
"daily-timer/internal/storage"
"daily-timer/internal/timer"
@@ -18,31 +19,51 @@ import (
)
type App struct {
ctx context.Context
store *storage.Storage
timer *timer.Timer
session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog
updater *updater.Updater
ctx context.Context
store *storage.Storage
timer *timer.Timer
session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string
jiraBaseURL string
relay *relay.Server
updater *updater.Updater
}
func New(store *storage.Storage) *App {
return &App{
store: store,
currentLogs: make(map[uint]*models.ParticipantLog),
relay: relay.New(),
updater: updater.New(),
}
}
func (a *App) Startup(ctx context.Context) {
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) {
// 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)
}
func (a *App) Shutdown(ctx context.Context) {
// Save window position before closing
a.saveWindowPosition()
if a.timer != nil {
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
func (a *App) GetParticipants() ([]models.Participant, error) {
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()
order := len(participants)
p := &models.Participant{
Name: name,
Email: email,
TimeLimit: timeLimit,
Order: order,
Active: true,
Name: name,
Email: email,
JiraFilter: jiraFilter,
TimeLimit: timeLimit,
Order: order,
Active: true,
}
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
}
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{
ID: id,
Name: name,
Email: email,
TimeLimit: timeLimit,
ID: id,
Name: name,
Email: email,
JiraFilter: jiraFilter,
TimeLimit: timeLimit,
}
return a.store.UpdateParticipant(p)
}
@@ -99,13 +141,14 @@ func (a *App) GetMeeting() (*models.Meeting, error) {
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()
if err != nil {
return err
}
meeting.Name = name
meeting.TimeLimit = timeLimit
meeting.JiraURL = jiraURL
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.SetQueue(queue)
a.currentLogs = make(map[uint]*models.ParticipantLog)
go a.handleTimerEvents()
a.timer.Start()
if jiraURL != "" {
go runtime.BrowserOpenURL(a.ctx, jiraURL)
}
return nil
}
@@ -177,15 +247,46 @@ func (a *App) handleTimerEvents() {
runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
case timer.EventSpeakerChanged:
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)
case timer.EventMeetingEnded:
a.saveSpeakerLog(event.State)
a.finalizeSpeakerLogs(event.State)
a.endMeetingSession(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) {
if a.session == nil {
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() {
if a.timer != nil {
a.timer.Pause()
@@ -508,3 +615,105 @@ func (a *App) DownloadAndInstallUpdate() error {
func (a *App) RestartApp() error {
return a.updater.RestartApp()
}
// Sound Management
func (a *App) getSoundsDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
if err := os.MkdirAll(soundsDir, 0755); err != nil {
return "", err
}
return soundsDir, nil
}
func (a *App) SelectCustomSound(soundType string) (string, error) {
// Validate sound type
validTypes := map[string]bool{"warning": true, "timeup": true, "meeting_end": true}
if !validTypes[soundType] {
return "", fmt.Errorf("invalid sound type: %s", soundType)
}
// Open file dialog
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Sound File",
Filters: []runtime.FileFilter{
{DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.m4a;*.ogg"},
},
})
if err != nil {
return "", err
}
if selection == "" {
return "", nil // User cancelled
}
// Get sounds directory
soundsDir, err := a.getSoundsDir()
if err != nil {
return "", err
}
// Determine destination filename
ext := filepath.Ext(selection)
destPath := filepath.Join(soundsDir, soundType+ext)
// Copy file
src, err := os.Open(selection)
if err != nil {
return "", err
}
defer func() { _ = src.Close() }()
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() { _ = dst.Close() }()
if _, err := dst.ReadFrom(src); err != nil {
return "", err
}
return destPath, nil
}
func (a *App) GetCustomSoundPath(soundType string) string {
soundsDir, err := a.getSoundsDir()
if err != nil {
return ""
}
// Check for common audio extensions
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func (a *App) ClearCustomSound(soundType string) error {
soundsDir, err := a.getSoundsDir()
if err != nil {
return err
}
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
for _, ext := range extensions {
path := filepath.Join(soundsDir, soundType+ext)
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return err
}
}
}
return nil
}

View File

@@ -5,19 +5,21 @@ import (
)
type Participant struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Email string `json:"email,omitempty"`
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
Order int `json:"order" gorm:"default:0"`
Active bool `json:"active" gorm:"default:true"`
CreatedAt time.Time `json:"createdAt" tsType:"string"`
UpdatedAt time.Time `json:"updatedAt" tsType:"string"`
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Email string `json:"email,omitempty"`
JiraFilter string `json:"jiraFilter,omitempty"`
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
Order int `json:"order" gorm:"default:0"`
Active bool `json:"active" gorm:"default:true"`
CreatedAt time.Time `json:"createdAt" tsType:"string"`
UpdatedAt time.Time `json:"updatedAt" tsType:"string"`
}
type Meeting struct {
ID uint `json:"id" gorm:"primaryKey"`
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)
Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"`
CreatedAt time.Time `json:"createdAt" tsType:"string"`
@@ -67,6 +69,9 @@ type Settings struct {
SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
Theme string `json:"theme" gorm:"default:dark"`
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
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"`
Name string `json:"name"`
TimeLimit int `json:"timeLimit"`
TimeSpent int `json:"timeSpent"`
Order int `json:"order"`
Status SpeakerStatus `json:"status"`
}

View File

@@ -2,6 +2,8 @@ package updater
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -43,6 +45,7 @@ type UpdateInfo struct {
ReleaseNotes string `json:"releaseNotes"`
DownloadURL string `json:"downloadURL"`
DownloadSize int64 `json:"downloadSize"`
IsRebuild bool `json:"isRebuild"`
}
type Updater struct {
@@ -89,16 +92,40 @@ func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
u.downloadURL = downloadAsset.BrowserDownloadURL
// Find checksum asset
var checksumAsset *Asset
for i := range release.Assets {
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".sha256") {
checksumAsset = &release.Assets[i]
break
}
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := strings.TrimPrefix(version.Version, "v")
isNewer := isNewerVersion(latestVersion, currentVersion)
isRebuild := false
// Check if same version but different checksum (rebuild)
if !isNewer && checksumAsset != nil {
remoteChecksum, err := u.downloadChecksum(checksumAsset.BrowserDownloadURL)
if err == nil {
localChecksum, err := u.calculateBinaryChecksum()
if err == nil && remoteChecksum != localChecksum {
isRebuild = true
}
}
}
info := &UpdateInfo{
Available: isNewerVersion(latestVersion, currentVersion),
Available: isNewer || isRebuild,
CurrentVersion: version.Version,
LatestVersion: release.TagName,
ReleaseNotes: release.Body,
DownloadURL: downloadAsset.BrowserDownloadURL,
DownloadSize: downloadAsset.Size,
IsRebuild: isRebuild,
}
return info, nil
@@ -160,12 +187,27 @@ func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
func (u *Updater) RestartApp() error {
destPath := filepath.Join(InstallPath, AppName)
// Launch new app
cmd := exec.Command("open", destPath)
// Use shell to launch new app after this process exits
// The sleep ensures the current app has time to exit
script := fmt.Sprintf(`sleep 1 && open "%s"`, destPath)
cmd := exec.Command("sh", "-c", script)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
// Detach the process so it continues after we exit
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch updated app: %w", err)
}
// Release the process so it doesn't become a zombie
go func() {
_ = cmd.Wait()
}()
// Give the shell time to start
time.Sleep(100 * time.Millisecond)
// Exit current app
os.Exit(0)
return nil
@@ -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")
func isNewerVersion(latest, current string) bool {
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)
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
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 {
return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
"name": p.Name,
"email": p.Email,
"time_limit": p.TimeLimit,
"name": p.Name,
"email": p.Email,
"jira_filter": p.JiraFilter,
"time_limit": p.TimeLimit,
}).Error
}

View File

@@ -21,7 +21,7 @@ const (
)
type Event struct {
Type EventType `json:"type"`
Type EventType `json:"type"`
State models.TimerState `json:"state"`
}
@@ -31,29 +31,29 @@ type Timer struct {
running bool
paused bool
meetingStartTime time.Time
meetingElapsed time.Duration
meetingLimit time.Duration
meetingStartTime time.Time
meetingElapsed time.Duration
meetingLimit time.Duration
speakerStartTime time.Time
speakerElapsed time.Duration
speakerLimit time.Duration
speakerStartTime time.Time
speakerElapsed time.Duration
speakerLimit time.Duration
currentSpeakerID uint
currentSpeaker string
speakingOrder int
queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo
currentSpeakerID uint
currentSpeaker string
speakingOrder int
queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo
warningThreshold time.Duration
speakerWarned bool
warningThreshold time.Duration
speakerWarned bool
speakerTimeUpEmitted bool
meetingWarned bool
meetingWarned bool
eventCh chan Event
ctx context.Context
cancel context.CancelFunc
pausedAt time.Time
eventCh chan Event
ctx context.Context
cancel context.CancelFunc
pausedAt time.Time
}
func New(meetingLimitSec, warningThresholdSec int) *Timer {
@@ -75,7 +75,7 @@ func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
t.mu.Lock()
defer t.mu.Unlock()
t.queue = speakers
// Initialize allSpeakers with pending status
t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
for i, s := range speakers {
@@ -105,25 +105,24 @@ func (t *Timer) Start() {
t.speakerWarned = false
t.meetingWarned = false
if len(t.queue) > 0 {
t.startNextSpeaker(now)
}
t.mu.Unlock()
// Не активируем участника автоматически!
go t.tick()
}
func (t *Timer) startNextSpeaker(now time.Time) {
func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
if len(t.queue) == 0 {
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 {
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
}
@@ -135,8 +134,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
t.currentSpeakerID = speaker.ID
t.currentSpeaker = speaker.Name
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
t.speakerStartTime = now
t.speakerElapsed = 0
t.speakerStartTime = now.Add(-offset)
t.speakerElapsed = offset
t.speakingOrder++
t.speakerWarned = false
t.speakerTimeUpEmitted = false
@@ -191,7 +190,7 @@ func (t *Timer) NextSpeaker() {
var eventType EventType
if len(t.queue) > 0 {
t.startNextSpeaker(now)
t.startNextSpeaker(now, 0)
eventType = EventSpeakerChanged
} else {
t.running = false
@@ -225,11 +224,14 @@ func (t *Timer) SkipSpeaker() {
now := time.Now()
if len(t.queue) > 1 {
t.startNextSpeaker(now)
t.startNextSpeaker(now, 0)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
} else {
// Only skipped speaker left - they need to speak now
t.startNextSpeaker(now, 0)
t.mu.Unlock()
t.emit(EventSpeakerChanged)
}
}
@@ -246,7 +248,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
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 {
if s.ID == speakerID {
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
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
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() {
t.mu.Lock()
@@ -299,9 +423,17 @@ func (t *Timer) Stop() {
t.mu.Unlock()
return
}
// Mark current speaker as done before stopping
// Mark current speaker as done and save their time spent
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.paused = false
@@ -321,7 +453,9 @@ func (t *Timer) buildState() models.TimerState {
if t.running && !t.paused {
now := time.Now()
speakerElapsed = now.Sub(t.speakerStartTime)
if t.currentSpeakerID != 0 {
speakerElapsed = now.Sub(t.speakerStartTime)
}
meetingElapsed = now.Sub(t.meetingStartTime)
}
@@ -332,7 +466,7 @@ func (t *Timer) buildState() models.TimerState {
// Copy allSpeakers to avoid data race and calculate total speakers time
allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
copy(allSpeakers, t.allSpeakers)
totalSpeakersTime := 0
for _, s := range t.allSpeakers {
totalSpeakersTime += s.TimeLimit
@@ -379,9 +513,15 @@ func (t *Timer) tick() {
}
now := time.Now()
speakerElapsed := now.Sub(t.speakerStartTime)
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
if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
t.speakerWarned = true