4 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
10 changed files with 105 additions and 43 deletions

View File

@@ -7,7 +7,7 @@
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, SwitchToSpeaker } 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' import { attendance } from './lib/stores'
@@ -36,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 () => {
@@ -96,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) {
@@ -290,7 +292,7 @@
{#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} on:switch={handleSwitchSpeaker} /> <ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<Controls {timerState} on:stop={() => meetingActive = false} /> <Controls {timerState} on:stop={() => meetingActive = false} />
</div> </div>
@@ -305,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">

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

View File

@@ -1,6 +1,6 @@
<script> <script>
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting, GetSettings } 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' import { attendance } from '../lib/stores'

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

@@ -44,7 +44,7 @@ export const translations = {
noSpeaker: 'Нет спикера', noSpeaker: 'Нет спикера',
noActiveMeeting: 'Собрание не запущено', noActiveMeeting: 'Собрание не запущено',
goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников', goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
readyToStart: 'Всё готово к началу', readyToStart: 'Всё готово к началу собрания',
editParticipants: 'Редактировать участников', editParticipants: 'Редактировать участников',
noParticipants: 'Участники не добавлены', noParticipants: 'Участники не добавлены',
registeredParticipants: 'Зарегистрированные участники', registeredParticipants: 'Зарегистрированные участники',
@@ -223,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',

View File

@@ -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>;

View File

@@ -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']();
} }

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"
@@ -24,6 +25,8 @@ type App struct {
session *models.MeetingSession session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string participantURLs map[uint]string
jiraBaseURL string
relay *relay.Server
updater *updater.Updater updater *updater.Updater
} }
@@ -31,12 +34,20 @@ 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) {
@@ -185,23 +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) a.participantURLs = make(map[uint]string)
if meeting.JiraURL != "" { if jiraURL != "" {
for _, p := range participants { for _, p := range participants {
url := meeting.JiraURL url := jiraURL
if p.JiraFilter != "" { if p.JiraFilter != "" {
url = meeting.JiraURL + "&quickFilter=" + p.JiraFilter url = jiraURL + "&quickFilter=" + p.JiraFilter
} }
a.participantURLs[p.ID] = url 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
} }
@@ -221,7 +248,11 @@ func (a *App) handleTimerEvents() {
case timer.EventSpeakerChanged: case timer.EventSpeakerChanged:
a.saveSpeakerLog(event.State) a.saveSpeakerLog(event.State)
if url, ok := a.participantURLs[event.State.CurrentSpeakerID]; ok && url != "" { if url, ok := a.participantURLs[event.State.CurrentSpeakerID]; ok && url != "" {
runtime.BrowserOpenURL(a.ctx, 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:

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)

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,16 +105,8 @@ func (t *Timer) Start() {
t.speakerWarned = false t.speakerWarned = false
t.meetingWarned = false t.meetingWarned = false
hasSpeakers := len(t.queue) > 0
if hasSpeakers {
t.startNextSpeaker(now, 0)
}
t.mu.Unlock() t.mu.Unlock()
// Не активируем участника автоматически!
if hasSpeakers {
t.emit(EventSpeakerChanged)
}
go t.tick() go t.tick()
} }
@@ -461,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)
} }
@@ -472,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
@@ -519,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