16 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
18 changed files with 1072 additions and 142 deletions

View File

@@ -81,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 \
@@ -101,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,7 @@
- 💾 **Экспорт** - экспорт данных в JSON или CSV - 💾 **Экспорт** - экспорт данных в JSON или CSV
- 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения - 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения
- 🌐 **Локализация** - русский и английский интерфейс - 🌐 **Локализация** - русский и английский интерфейс
- 🔗 **Jira интеграция** - автоматическое открытие Jira-фильтра в браузере при ходе участника
- 🔄 **Автообновление** - проверка и установка обновлений из приложения - 🔄 **Автообновление** - проверка и установка обновлений из приложения
## Скриншоты ## Скриншоты
@@ -96,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. **Отметить присутствие** - переключить статус присутствия/отсутствия
### Во время митинга ### Во время митинга
@@ -105,8 +107,9 @@ xattr -cr "Daily Timer.app"
2. Таймер показывает текущего спикера с обратным отсчётом 2. Таймер показывает текущего спикера с обратным отсчётом
3. Нажать **Следующий** для перехода (или ⌘N) 3. Нажать **Следующий** для перехода (или ⌘N)
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди 4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
5. Использовать **Пауза/Продолжить** для прерываний 5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
6. Нажать **Стоп** для досрочного завершения 6. Использовать **Пауза/Продолжить** для прерываний
7. Нажать **Стоп** для досрочного завершения
### Горячие клавиши ### Горячие клавиши
@@ -185,7 +188,7 @@ GITEA_TOKEN=<token> make release-publish
## Планы ## Планы
- [ ] Drag-and-drop для порядка участников - [x] Drag-and-drop для порядка участников
- [ ] Интеграция с Telegram (отправка сводки митинга) - [ ] Интеграция с Telegram (отправка сводки митинга)
- [ ] Интеграция с календарём (авто-расписание) - [ ] Интеграция с календарём (авто-расписание)
- [ ] Шаблоны команд - [ ] Шаблоны команд

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,7 @@
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 // Warm up AudioContext on first user interaction
const warmUpAudio = async () => { const warmUpAudio = async () => {
@@ -95,6 +97,7 @@
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) {
@@ -192,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 = []
@@ -217,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)
@@ -266,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}
@@ -285,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">
@@ -333,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;
@@ -373,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,19 +1,49 @@
<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 $: 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) { function formatTime(seconds) {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60)
const secs = seconds % 60 const secs = seconds % 60
@@ -25,9 +55,14 @@
<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-display"> <span class="time-display">
@@ -47,8 +82,8 @@
<span class="time-limit">{formatTime(speaker.timeLimit)}</span> <span class="time-limit">{formatTime(speaker.timeLimit)}</span>
{/if} {/if}
</span> </span>
{#if speaker.status === 'pending' || speaker.status === 'skipped'} {#if speaker.status === 'pending' || speaker.status === 'speaking'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}"> <button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
</button> </button>
{/if} {/if}
@@ -108,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,
@@ -181,12 +228,12 @@
} }
.time-display { .time-display {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px;
font-family: 'SF Mono', 'Menlo', monospace; font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap;
} }
.time-spent { .time-spent {
@@ -199,6 +246,7 @@
.time-sep { .time-sep {
color: #6b7280; color: #6b7280;
margin: 0 3px;
} }
.time-limit { .time-limit {

View File

@@ -12,6 +12,9 @@
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
@@ -236,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
} }
@@ -251,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
@@ -312,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>
@@ -526,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;
@@ -609,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;
@@ -836,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: 'Зарегистрированные участники',
@@ -103,6 +105,7 @@ export const translations = {
seconds: 'сек', seconds: 'сек',
defaultSpeakerTime: 'Время на спикера по умолчанию', defaultSpeakerTime: 'Время на спикера по умолчанию',
defaultTotalTime: 'Общее время собрания (мин)', defaultTotalTime: 'Общее время собрания (мин)',
defaultJiraUrl: 'Jira URL Панели Kanban',
theme: 'Тема оформления', theme: 'Тема оформления',
themeDark: 'Тёмная', themeDark: 'Тёмная',
themeLight: 'Светлая', themeLight: 'Светлая',
@@ -139,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: 'Всего собраний',
@@ -196,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
@@ -215,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',
@@ -276,6 +284,7 @@ export const translations = {
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',
@@ -312,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,7 +3,7 @@
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>;
@@ -43,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>;
@@ -53,6 +55,8 @@ 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 SelectCustomSound(arg1:string):Promise<string>;
export function SkipSpeaker():Promise<void>; export function SkipSpeaker():Promise<void>;
@@ -61,8 +65,10 @@ export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Pr
export function StopMeeting():Promise<void>; export function StopMeeting():Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>; export function SwitchToSpeaker(arg1:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>; export function UpdateMeeting(arg1:string,arg2:number,arg3:string):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number,arg5:string):Promise<void>;
export function UpdateSettings(arg1:models.Settings):Promise<void>; export function UpdateSettings(arg1:models.Settings):Promise<void>;

View File

@@ -2,8 +2,8 @@
// 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() {
@@ -82,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']();
} }
@@ -102,6 +106,10 @@ 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) { export function SelectCustomSound(arg1) {
return window['go']['app']['App']['SelectCustomSound'](arg1); return window['go']['app']['App']['SelectCustomSound'](arg1);
} }
@@ -118,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,6 +352,9 @@ 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 {

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()

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

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

View File

@@ -21,7 +21,7 @@ const (
) )
type Event struct { type Event struct {
Type EventType `json:"type"` Type EventType `json:"type"`
State models.TimerState `json:"state"` State models.TimerState `json:"state"`
} }
@@ -31,29 +31,29 @@ type Timer struct {
running bool running bool
paused bool paused bool
meetingStartTime time.Time meetingStartTime time.Time
meetingElapsed time.Duration meetingElapsed time.Duration
meetingLimit time.Duration meetingLimit time.Duration
speakerStartTime time.Time speakerStartTime time.Time
speakerElapsed time.Duration speakerElapsed time.Duration
speakerLimit time.Duration speakerLimit time.Duration
currentSpeakerID uint currentSpeakerID uint
currentSpeaker string currentSpeaker string
speakingOrder int speakingOrder int
queue []models.QueuedSpeaker queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo allSpeakers []models.SpeakerInfo
warningThreshold time.Duration warningThreshold time.Duration
speakerWarned bool speakerWarned bool
speakerTimeUpEmitted bool speakerTimeUpEmitted bool
meetingWarned bool meetingWarned bool
eventCh chan Event eventCh chan Event
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
pausedAt time.Time pausedAt time.Time
} }
func New(meetingLimitSec, warningThresholdSec int) *Timer { func New(meetingLimitSec, warningThresholdSec int) *Timer {
@@ -75,7 +75,7 @@ func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.queue = speakers t.queue = speakers
// Initialize allSpeakers with pending status // Initialize allSpeakers with pending status
t.allSpeakers = make([]models.SpeakerInfo, len(speakers)) t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
for i, s := range speakers { for i, s := range speakers {
@@ -105,15 +105,12 @@ 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
} }
@@ -137,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
@@ -193,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
@@ -227,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)
} }
} }
@@ -248,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:]...)
@@ -256,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()
@@ -331,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)
} }
@@ -342,7 +466,7 @@ func (t *Timer) buildState() models.TimerState {
// Copy allSpeakers to avoid data race and calculate total speakers time // Copy allSpeakers to avoid data race and calculate total speakers time
allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers)) allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
copy(allSpeakers, t.allSpeakers) copy(allSpeakers, t.allSpeakers)
totalSpeakersTime := 0 totalSpeakersTime := 0
for _, s := range t.allSpeakers { for _, s := range t.allSpeakers {
totalSpeakersTime += s.TimeLimit totalSpeakersTime += s.TimeLimit
@@ -389,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