feat: add jira filter url per participant and meeting jira url

This commit is contained in:
Mikhail Kiselev
2026-03-13 01:42:24 +03:00
parent 1620e12115
commit 93c91161ba
8 changed files with 198 additions and 42 deletions

View File

@@ -17,6 +17,7 @@
let editingId = null let editingId = null
let editName = '' let editName = ''
let editTimeLimitMin = 2 let editTimeLimitMin = 2
let editJiraFilter = ''
// Meeting name editing // Meeting name editing
let editingMeetingName = false let editingMeetingName = false
@@ -26,6 +27,9 @@
let editingMeetingTime = false let editingMeetingTime = false
let meetingTimeInput = 60 let meetingTimeInput = 60
// Meeting Jira URL editing
let editingMeetingJiraUrl = false
let meetingJiraUrlInput = ''
onMount(async () => { onMount(async () => {
await loadData() await loadData()
}) })
@@ -48,7 +52,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) {
@@ -71,19 +75,21 @@
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 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) {
@@ -187,7 +193,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) {
@@ -207,7 +213,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) {
@@ -215,11 +221,32 @@
} }
} }
function startEditMeetingJiraUrl() {
meetingJiraUrlInput = meeting?.jiraUrl || ''
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>
@@ -275,6 +302,29 @@
<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={startEditMeetingJiraUrl} class="editable-jira">
{$t('setup.jiraUrl')}: {meeting?.jiraUrl ? '🔗' : '—'}
<span class="edit-icon"></span>
</p>
{/if}
</div> </div>
<div class="add-participant"> <div class="add-participant">
@@ -334,6 +384,9 @@
<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>
{#if p.jiraFilter}
<span class="url-indicator" title="{meeting?.jiraUrl}&quickFilter={p.jiraFilter}">🔗</span>
{/if}
<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>
@@ -363,6 +416,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>
@@ -529,6 +589,62 @@
cursor: pointer; cursor: pointer;
} }
.header p.editable-jira {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin: 4px 0 0 0;
}
.header p.editable-jira:hover {
color: #4a90d9;
}
.header p.editable-jira:hover .edit-icon {
opacity: 1;
}
.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;
@@ -677,6 +793,13 @@
font-size: 12px; font-size: 12px;
} }
.url-indicator {
font-size: 12px;
opacity: 0.7;
cursor: default;
flex-shrink: 0;
}
.edit { .edit {
padding: 4px 8px; padding: 4px 8px;
background: transparent; background: transparent;

View File

@@ -27,6 +27,8 @@ export const translations = {
minutes: 'мин', minutes: 'мин',
unlimited: 'Без ограничения', unlimited: 'Без ограничения',
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие', dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
jiraUrl: 'Jira URL собрания',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
}, },
// Timer page // Timer page
@@ -139,6 +141,8 @@ export const translations = {
edit: 'Редактировать', edit: 'Редактировать',
delete: 'Удалить', delete: 'Удалить',
name: 'Имя', name: 'Имя',
jiraFilter: 'Jira Quick Filter',
jiraFilterPlaceholder: 'quickFilter ID (напр. 12345)',
stats: 'Статистика', stats: 'Статистика',
avgSpeakTime: 'Среднее время выступления', avgSpeakTime: 'Среднее время выступления',
totalMeetings: 'Всего собраний', totalMeetings: 'Всего собраний',
@@ -200,6 +204,8 @@ export const translations = {
minutes: 'min', minutes: 'min',
unlimited: 'Unlimited', unlimited: 'Unlimited',
dragHint: 'drag to reorder, ✓/✗ attendance', dragHint: 'drag to reorder, ✓/✗ attendance',
jiraUrl: 'Meeting Jira URL',
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
}, },
// Timer page // Timer page
@@ -312,6 +318,8 @@ export const translations = {
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
name: 'Name', name: 'Name',
jiraFilter: 'Jira Quick Filter',
jiraFilterPlaceholder: 'quickFilter ID (e.g. 12345)',
stats: 'Statistics', stats: 'Statistics',
avgSpeakTime: 'Avg Speaking Time', avgSpeakTime: 'Avg Speaking Time',
totalMeetings: 'Total Meetings', totalMeetings: 'Total Meetings',

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>;
@@ -65,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() {
@@ -126,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);

View File

@@ -23,6 +23,7 @@ type App struct {
timer *timer.Timer timer *timer.Timer
session *models.MeetingSession session *models.MeetingSession
currentLogs map[uint]*models.ParticipantLog currentLogs map[uint]*models.ParticipantLog
participantURLs map[uint]string
updater *updater.Updater updater *updater.Updater
} }
@@ -85,13 +86,14 @@ 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,
JiraFilter: jiraFilter,
TimeLimit: timeLimit, TimeLimit: timeLimit,
Order: order, Order: order,
Active: true, Active: true,
@@ -103,11 +105,12 @@ 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,
JiraFilter: jiraFilter,
TimeLimit: timeLimit, 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

@@ -8,6 +8,7 @@ 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"`
JiraFilter string `json:"jiraFilter,omitempty"`
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
Order int `json:"order" gorm:"default:0"` Order int `json:"order" gorm:"default:0"`
Active bool `json:"active" gorm:"default:true"` Active bool `json:"active" gorm:"default:true"`
@@ -18,6 +19,7 @@ type Participant struct {
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"`

View File

@@ -119,6 +119,7 @@ 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,
"jira_filter": p.JiraFilter,
"time_limit": p.TimeLimit, "time_limit": p.TimeLimit,
}).Error }).Error
} }