9 Commits

Author SHA1 Message Date
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
14 changed files with 636 additions and 83 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. **Отметить присутствие** - переключить статус присутствия/отсутствия
### Во время митинга ### Во время митинга
@@ -186,7 +188,7 @@ GITEA_TOKEN=<token> make release-publish
## Планы ## Планы
- [ ] Drag-and-drop для порядка участников - [x] Drag-and-drop для порядка участников
- [ ] Интеграция с Telegram (отправка сводки митинга) - [ ] Интеграция с Telegram (отправка сводки митинга)
- [ ] Интеграция с календарём (авто-расписание) - [ ] Интеграция с календарём (авто-расписание)
- [ ] Шаблоны команд - [ ] Шаблоны команд

View File

@@ -9,6 +9,7 @@
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime' import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } from '../wailsjs/go/app/App' import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } 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
@@ -222,6 +223,7 @@
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 = []
@@ -231,12 +233,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)

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

@@ -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
@@ -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
@@ -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>;
@@ -53,6 +53,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>;
@@ -63,8 +65,8 @@ export function StopMeeting():Promise<void>;
export function SwitchToSpeaker(arg1:number):Promise<void>; export function SwitchToSpeaker(arg1:number):Promise<void>;
export function UpdateMeeting(arg1:string,arg2: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):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() {
@@ -102,6 +102,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);
} }
@@ -122,12 +126,12 @@ export function SwitchToSpeaker(arg1) {
return window['go']['app']['App']['SwitchToSpeaker'](arg1); return window['go']['app']['App']['SwitchToSpeaker'](arg1);
} }
export function UpdateMeeting(arg1, arg2) { export function UpdateMeeting(arg1, arg2, arg3) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2); return window['go']['app']['App']['UpdateMeeting'](arg1, arg2, arg3);
} }
export function UpdateParticipant(arg1, arg2, arg3, arg4) { export function UpdateParticipant(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4); 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

@@ -18,12 +18,13 @@ 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
updater *updater.Updater
} }
func New(store *storage.Storage) *App { func New(store *storage.Storage) *App {
@@ -85,16 +86,17 @@ 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 {
@@ -103,12 +105,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)
} }
@@ -127,13 +130,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)
} }
@@ -181,6 +185,17 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
} }
} }
a.participantURLs = make(map[uint]string)
if meeting.JiraURL != "" {
for _, p := range participants {
url := meeting.JiraURL
if p.JiraFilter != "" {
url = meeting.JiraURL + "&quickFilter=" + p.JiraFilter
}
a.participantURLs[p.ID] = url
}
}
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)
@@ -205,6 +220,9 @@ 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 != "" {
runtime.BrowserOpenURL(a.ctx, url)
}
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.finalizeSpeakerLogs(event.State) a.finalizeSpeakerLogs(event.State)

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"`
@@ -71,4 +73,5 @@ type Settings struct {
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) WindowX int `json:"windowX" gorm:"default:-1"` // -1 = not set (center)
WindowY int `json:"windowY" 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

@@ -117,9 +117,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

@@ -105,11 +105,16 @@ func (t *Timer) Start() {
t.speakerWarned = false t.speakerWarned = false
t.meetingWarned = false t.meetingWarned = false
if len(t.queue) > 0 { hasSpeakers := len(t.queue) > 0
if hasSpeakers {
t.startNextSpeaker(now, 0) t.startNextSpeaker(now, 0)
} }
t.mu.Unlock() t.mu.Unlock()
if hasSpeakers {
t.emit(EventSpeakerChanged)
}
go t.tick() go t.tick()
} }