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 Setup from './components/Setup.svelte'
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 { attendance } from './lib/stores'
@@ -36,7 +36,7 @@
EventsOn('timer:meeting_warning', handleMeetingWarning)
EventsOn('timer:meeting_ended', handleMeetingEnded)
EventsOn('timer:speaker_changed', handleSpeakerChanged)
EventsOn('jira:open', handleJiraOpen)
EventsOn('jira:open', (url) => { if (url) OpenBrowserURL(url) })
// Warm up AudioContext on first user interaction
const warmUpAudio = async () => {
@@ -134,10 +134,6 @@
timerState = state
}
function handleJiraOpen(url) {
window.open(url, 'jira-kanban')
}
let audioContext = null
function getAudioContext() {
@@ -296,7 +292,7 @@
{#if currentView === 'main'}
{#if meetingActive && timerState}
<div class="timer-view">
<Timer {timerState} />
<Timer {timerState} jiraUrl={settings?.defaultJiraUrl || ''} />
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<Controls {timerState} on:stop={() => meetingActive = false} />
</div>
@@ -311,6 +307,9 @@
<button class="secondary-btn" on:click={() => currentView = 'setup'}>
{$t('timer.editParticipants')}
</button>
{#if settings?.defaultJiraUrl}
<button class="secondary-btn" on:click={() => OpenBrowserURL(settings.defaultJiraUrl)}>🔗 Jira</button>
{/if}
</div>
{:else}
<div class="no-meeting">

View File

@@ -6,8 +6,9 @@
let sessions = []
let stats = null
let loading = true
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
let dateTo = new Date().toISOString().split('T')[0]
const _now = new Date()
let dateFrom = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, '0')}-01`
let dateTo = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, '0')}-${String(_now.getDate()).padStart(2, '0')}`
let exporting = false
let showDeleteAllConfirm = false
let deletingSessionId = null

View File

@@ -1,6 +1,6 @@
<script>
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 { attendance } from '../lib/stores'

View File

@@ -1,8 +1,10 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { t } from '../lib/i18n'
import { OpenBrowserURL } from '../../wailsjs/go/app/App'
export let timerState
export let jiraUrl = ''
let currentTime = ''
let clockInterval
@@ -63,6 +65,9 @@
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
<div class="header-row">
<div class="current-clock">{currentTime}</div>
{#if jiraUrl}
<button class="jira-btn" on:click={() => OpenBrowserURL(jiraUrl)} title="Открыть Jira">🔗</button>
{/if}
<div class="help-icon">
?
<div class="help-tooltip">
@@ -204,6 +209,17 @@
margin-bottom: 8px;
}
.jira-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
opacity: 0.6;
transition: opacity 0.2s;
}
.jira-btn:hover { opacity: 1; }
.current-clock {
font-size: 14px;
color: #8899a6;

View File

@@ -43,6 +43,8 @@ export function GetVersion():Promise<string>;
export function NextSpeaker():Promise<void>;
export function OpenBrowserURL(arg1:string):Promise<void>;
export function PauseMeeting():Promise<void>;
export function RemoveFromQueue(arg1:number):Promise<void>;

View File

@@ -82,6 +82,10 @@ export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker']();
}
export function OpenBrowserURL(arg1) {
return window['go']['app']['App']['OpenBrowserURL'](arg1);
}
export function PauseMeeting() {
return window['go']['app']['App']['PauseMeeting']();
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"daily-timer/internal/models"
"daily-timer/internal/relay"
"daily-timer/internal/services/updater"
"daily-timer/internal/storage"
"daily-timer/internal/timer"
@@ -25,6 +26,7 @@ type App struct {
currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string
jiraBaseURL string
relay *relay.Server
updater *updater.Updater
}
@@ -32,12 +34,20 @@ func New(store *storage.Storage) *App {
return &App{
store: store,
currentLogs: make(map[uint]*models.ParticipantLog),
relay: relay.New(),
updater: updater.New(),
}
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.relay.Start()
}
func (a *App) OpenBrowserURL(url string) {
if url != "" {
runtime.BrowserOpenURL(a.ctx, url)
}
}
func (a *App) OnDomReady(ctx context.Context) {
@@ -205,7 +215,8 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
}
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)
@@ -214,6 +225,10 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
go a.handleTimerEvents()
a.timer.Start()
if jiraURL != "" {
go runtime.BrowserOpenURL(a.ctx, jiraURL)
}
return nil
}
@@ -233,8 +248,10 @@ func (a *App) handleTimerEvents() {
case timer.EventSpeakerChanged:
a.saveSpeakerLog(event.State)
if url, ok := a.participantURLs[event.State.CurrentSpeakerID]; ok && url != "" {
a.relay.Broadcast(url)
runtime.EventsEmit(a.ctx, "jira:open", url)
} else if a.jiraBaseURL != "" {
a.relay.Broadcast(a.jiraBaseURL)
runtime.EventsEmit(a.ctx, "jira:open", a.jiraBaseURL)
}
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)

View File

@@ -79,6 +79,9 @@ func (s *Storage) ensureDefaults() error {
// Migrate old default value to new default (60 min instead of 15)
s.db.Model(&settings).Update("default_meeting_time", 3600)
}
if settings.DefaultJiraUrl == "" {
s.db.Model(&settings).Update("default_jira_url", "https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN")
}
var meeting models.Meeting
result = s.db.First(&meeting)

View File

@@ -21,7 +21,7 @@ const (
)
type Event struct {
Type EventType `json:"type"`
Type EventType `json:"type"`
State models.TimerState `json:"state"`
}
@@ -31,29 +31,29 @@ type Timer struct {
running bool
paused bool
meetingStartTime time.Time
meetingElapsed time.Duration
meetingLimit time.Duration
meetingStartTime time.Time
meetingElapsed time.Duration
meetingLimit time.Duration
speakerStartTime time.Time
speakerElapsed time.Duration
speakerLimit time.Duration
speakerStartTime time.Time
speakerElapsed time.Duration
speakerLimit time.Duration
currentSpeakerID uint
currentSpeaker string
speakingOrder int
queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo
currentSpeakerID uint
currentSpeaker string
speakingOrder int
queue []models.QueuedSpeaker
allSpeakers []models.SpeakerInfo
warningThreshold time.Duration
speakerWarned bool
warningThreshold time.Duration
speakerWarned bool
speakerTimeUpEmitted bool
meetingWarned bool
meetingWarned bool
eventCh chan Event
ctx context.Context
cancel context.CancelFunc
pausedAt time.Time
eventCh chan Event
ctx context.Context
cancel context.CancelFunc
pausedAt time.Time
}
func New(meetingLimitSec, warningThresholdSec int) *Timer {
@@ -105,16 +105,8 @@ func (t *Timer) Start() {
t.speakerWarned = false
t.meetingWarned = false
hasSpeakers := len(t.queue) > 0
if hasSpeakers {
t.startNextSpeaker(now, 0)
}
t.mu.Unlock()
if hasSpeakers {
t.emit(EventSpeakerChanged)
}
// Не активируем участника автоматически!
go t.tick()
}
@@ -461,7 +453,9 @@ func (t *Timer) buildState() models.TimerState {
if t.running && !t.paused {
now := time.Now()
speakerElapsed = now.Sub(t.speakerStartTime)
if t.currentSpeakerID != 0 {
speakerElapsed = now.Sub(t.speakerStartTime)
}
meetingElapsed = now.Sub(t.meetingStartTime)
}
@@ -519,9 +513,15 @@ func (t *Timer) tick() {
}
now := time.Now()
speakerElapsed := now.Sub(t.speakerStartTime)
meetingElapsed := now.Sub(t.meetingStartTime)
if t.currentSpeakerID == 0 {
t.mu.Unlock()
t.emit(EventTick)
continue
}
speakerElapsed := now.Sub(t.speakerStartTime)
remaining := t.speakerLimit - speakerElapsed
if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
t.speakerWarned = true