diff --git a/frontend/src/components/Setup.svelte b/frontend/src/components/Setup.svelte index b8368e7..989b401 100644 --- a/frontend/src/components/Setup.svelte +++ b/frontend/src/components/Setup.svelte @@ -17,6 +17,7 @@ let editingId = null let editName = '' let editTimeLimitMin = 2 + let editJiraFilter = '' // Meeting name editing let editingMeetingName = false @@ -26,6 +27,9 @@ let editingMeetingTime = false let meetingTimeInput = 60 + // Meeting Jira URL editing + let editingMeetingJiraUrl = false + let meetingJiraUrlInput = '' onMount(async () => { await loadData() }) @@ -48,7 +52,7 @@ if (!newName.trim()) return try { - await AddParticipant(newName.trim(), '', newTimeLimitMin * 60) + await AddParticipant(newName.trim(), '', newTimeLimitMin * 60, '') newName = '' await loadData() } catch (e) { @@ -71,19 +75,21 @@ editingId = p.id editName = p.name editTimeLimitMin = Math.floor(p.timeLimit / 60) + editJiraFilter = p.jiraFilter || '' } function cancelEdit() { editingId = null editName = '' editTimeLimitMin = 2 + editJiraFilter = '' } async function saveEdit() { if (!editName.trim() || editingId === null) return try { - await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60) + await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60, editJiraFilter.trim()) editingId = null await loadData() } catch (e) { @@ -187,7 +193,7 @@ async function saveMeetingName() { if (!meetingNameInput.trim()) return try { - await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600) + await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600, meeting?.jiraUrl || '') meeting = await GetMeeting() editingMeetingName = false } catch (e) { @@ -207,7 +213,7 @@ async function saveMeetingTime() { if (meetingTimeInput < 1) return try { - await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60) + await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60, meeting?.jiraUrl || '') meeting = await GetMeeting() editingMeetingTime = false } 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) { if (e.key === 'Escape') { if (editingId !== null) cancelEdit() if (editingMeetingName) cancelEditMeetingName() if (editingMeetingTime) cancelEditMeetingTime() + if (editingMeetingJiraUrl) cancelEditMeetingJiraUrl() } } @@ -275,6 +302,29 @@

{/if} + {#if editingMeetingJiraUrl} +
+ + { + if (e.key === 'Enter') saveMeetingJiraUrl() + if (e.key === 'Escape') cancelEditMeetingJiraUrl() + }} + autofocus + /> + + +
+ {:else} + +

+ {$t('setup.jiraUrl')}: {meeting?.jiraUrl ? '🔗' : '—'} + +

+ {/if}
@@ -334,6 +384,9 @@ {p.name} {Math.floor(p.timeLimit / 60)} {$t('setup.minutes')} + {#if p.jiraFilter} + 🔗 + {/if} @@ -363,6 +416,13 @@ if (e.key === 'Escape') cancelEdit() }} />
+
+ + { + if (e.key === 'Enter') saveEdit() + if (e.key === 'Escape') cancelEdit() + }} /> +
@@ -529,6 +589,62 @@ 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 { display: flex; gap: 6px; @@ -677,6 +793,13 @@ font-size: 12px; } + .url-indicator { + font-size: 12px; + opacity: 0.7; + cursor: default; + flex-shrink: 0; + } + .edit { padding: 4px 8px; background: transparent; diff --git a/frontend/src/lib/i18n.js b/frontend/src/lib/i18n.js index 16b13ad..e8fc208 100644 --- a/frontend/src/lib/i18n.js +++ b/frontend/src/lib/i18n.js @@ -27,6 +27,8 @@ export const translations = { minutes: 'мин', unlimited: 'Без ограничения', dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие', + jiraUrl: 'Jira URL собрания', + jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...', }, // Timer page @@ -139,6 +141,8 @@ export const translations = { edit: 'Редактировать', delete: 'Удалить', name: 'Имя', + jiraFilter: 'Jira Quick Filter', + jiraFilterPlaceholder: 'quickFilter ID (напр. 12345)', stats: 'Статистика', avgSpeakTime: 'Среднее время выступления', totalMeetings: 'Всего собраний', @@ -200,6 +204,8 @@ export const translations = { minutes: 'min', unlimited: 'Unlimited', dragHint: 'drag to reorder, ✓/✗ attendance', + jiraUrl: 'Meeting Jira URL', + jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...', }, // Timer page @@ -312,6 +318,8 @@ export const translations = { edit: 'Edit', delete: 'Delete', name: 'Name', + jiraFilter: 'Jira Quick Filter', + jiraFilterPlaceholder: 'quickFilter ID (e.g. 12345)', stats: 'Statistics', avgSpeakTime: 'Avg Speaking Time', totalMeetings: 'Total Meetings', diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index a38463e..07fcc0a 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -3,7 +3,7 @@ import {models} from '../models'; import {updater} from '../models'; -export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise; +export function AddParticipant(arg1:string,arg2:string,arg3:number,arg4:string):Promise; export function CheckForUpdates():Promise; @@ -65,8 +65,8 @@ export function StopMeeting():Promise; export function SwitchToSpeaker(arg1:number):Promise; -export function UpdateMeeting(arg1:string,arg2:number):Promise; +export function UpdateMeeting(arg1:string,arg2:number,arg3:string):Promise; -export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise; +export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number,arg5:string):Promise; export function UpdateSettings(arg1:models.Settings):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index fbfe9b3..6af5abd 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -2,8 +2,8 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -export function AddParticipant(arg1, arg2, arg3) { - return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3); +export function AddParticipant(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3, arg4); } export function CheckForUpdates() { @@ -126,12 +126,12 @@ export function SwitchToSpeaker(arg1) { return window['go']['app']['App']['SwitchToSpeaker'](arg1); } -export function UpdateMeeting(arg1, arg2) { - return window['go']['app']['App']['UpdateMeeting'](arg1, arg2); +export function UpdateMeeting(arg1, arg2, arg3) { + return window['go']['app']['App']['UpdateMeeting'](arg1, arg2, arg3); } -export function UpdateParticipant(arg1, arg2, arg3, arg4) { - return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4); +export function UpdateParticipant(arg1, arg2, arg3, arg4, arg5) { + return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4, arg5); } export function UpdateSettings(arg1) { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 73e5378..d9f288c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -112,6 +112,7 @@ export namespace models { id: number; name: string; email?: string; + jiraFilter?: string; timeLimit: number; order: number; active: boolean; @@ -129,6 +130,7 @@ export namespace models { this.id = source["id"]; this.name = source["name"]; this.email = source["email"]; + this.jiraFilter = source["jiraFilter"]; this.timeLimit = source["timeLimit"]; this.order = source["order"]; this.active = source["active"]; @@ -253,6 +255,7 @@ export namespace models { export class Meeting { id: number; name: string; + jiraUrl?: string; timeLimit: number; sessions?: MeetingSession[]; // Go type: time @@ -268,6 +271,7 @@ export namespace models { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; this.name = source["name"]; + this.jiraUrl = source["jiraUrl"]; this.timeLimit = source["timeLimit"]; this.sessions = this.convertValues(source["sessions"], MeetingSession); this.createdAt = this.convertValues(source["createdAt"], null); diff --git a/internal/app/app.go b/internal/app/app.go index e66946c..2fe15ed 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,12 +18,13 @@ import ( ) type App struct { - ctx context.Context - store *storage.Storage - timer *timer.Timer - session *models.MeetingSession - currentLogs map[uint]*models.ParticipantLog - updater *updater.Updater + ctx context.Context + store *storage.Storage + timer *timer.Timer + session *models.MeetingSession + currentLogs map[uint]*models.ParticipantLog + participantURLs map[uint]string + updater *updater.Updater } func New(store *storage.Storage) *App { @@ -85,16 +86,17 @@ func (a *App) GetParticipants() ([]models.Participant, error) { 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() order := len(participants) p := &models.Participant{ - Name: name, - Email: email, - TimeLimit: timeLimit, - Order: order, - Active: true, + Name: name, + Email: email, + JiraFilter: jiraFilter, + TimeLimit: timeLimit, + Order: order, + Active: true, } 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 } -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{ - ID: id, - Name: name, - Email: email, - TimeLimit: timeLimit, + ID: id, + Name: name, + Email: email, + JiraFilter: jiraFilter, + TimeLimit: timeLimit, } return a.store.UpdateParticipant(p) } @@ -127,13 +130,14 @@ func (a *App) GetMeeting() (*models.Meeting, error) { 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() if err != nil { return err } meeting.Name = name meeting.TimeLimit = timeLimit + meeting.JiraURL = jiraURL 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.SetQueue(queue) a.currentLogs = make(map[uint]*models.ParticipantLog) @@ -205,6 +220,9 @@ func (a *App) handleTimerEvents() { runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State) case timer.EventSpeakerChanged: 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) case timer.EventMeetingEnded: a.finalizeSpeakerLogs(event.State) diff --git a/internal/models/models.go b/internal/models/models.go index 267c469..9c59331 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -5,19 +5,21 @@ import ( ) type Participant struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Email string `json:"email,omitempty"` - TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds - Order int `json:"order" gorm:"default:0"` - Active bool `json:"active" gorm:"default:true"` - CreatedAt time.Time `json:"createdAt" tsType:"string"` - UpdatedAt time.Time `json:"updatedAt" tsType:"string"` + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` + Email string `json:"email,omitempty"` + JiraFilter string `json:"jiraFilter,omitempty"` + TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds + Order int `json:"order" gorm:"default:0"` + Active bool `json:"active" gorm:"default:true"` + CreatedAt time.Time `json:"createdAt" tsType:"string"` + UpdatedAt time.Time `json:"updatedAt" tsType:"string"` } type Meeting struct { ID uint `json:"id" gorm:"primaryKey"` 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) Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"` CreatedAt time.Time `json:"createdAt" tsType:"string"` diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 4e1b164..da01667 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -117,9 +117,10 @@ func (s *Storage) CreateParticipant(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{}{ - "name": p.Name, - "email": p.Email, - "time_limit": p.TimeLimit, + "name": p.Name, + "email": p.Email, + "jira_filter": p.JiraFilter, + "time_limit": p.TimeLimit, }).Error }