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
This commit is contained in:
Mikhail Kiselev
2026-04-03 03:31:00 +03:00
parent 79214910f1
commit 41bc35fd43
9 changed files with 85 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,7 +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', handleJiraOpen) 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 () => {
@@ -134,10 +134,6 @@
timerState = state timerState = state
} }
function handleJiraOpen(url) {
window.open(url, 'jira-kanban')
}
let audioContext = null let audioContext = null
function getAudioContext() { function getAudioContext() {
@@ -296,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>
@@ -311,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

@@ -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"
@@ -25,6 +26,7 @@ type App struct {
currentLogs map[uint]*models.ParticipantLog currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string participantURLs map[uint]string
jiraBaseURL string jiraBaseURL string
relay *relay.Server
updater *updater.Updater updater *updater.Updater
} }
@@ -32,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) {
@@ -205,7 +215,8 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
} }
if jiraURL != "" { if jiraURL != "" {
runtime.EventsEmit(a.ctx, "jira:open", 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)
@@ -214,6 +225,10 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
go a.handleTimerEvents() go a.handleTimerEvents()
a.timer.Start() a.timer.Start()
if jiraURL != "" {
go runtime.BrowserOpenURL(a.ctx, jiraURL)
}
return nil return nil
} }
@@ -233,8 +248,10 @@ 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 != "" {
a.relay.Broadcast(url)
runtime.EventsEmit(a.ctx, "jira:open", url) runtime.EventsEmit(a.ctx, "jira:open", url)
} else if a.jiraBaseURL != "" { } else if a.jiraBaseURL != "" {
a.relay.Broadcast(a.jiraBaseURL)
runtime.EventsEmit(a.ctx, "jira:open", 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)

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