//go:build windows package main import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "sync" "syscall" "time" "unsafe" ) type historyCalendarCell struct { Date time.Time Rect winRect Count int Selected bool InRange bool } type historyDetailRow struct { Key string Rect winRect WorldLabel string Start time.Time End time.Time Users []string Lines []string Selected bool Current bool } type historyEventRecord struct { At time.Time Kind string Name string Raw string } type historyVisitRecord struct { Key string WorldLabel string WorldID string InstanceID string Start time.Time End time.Time Current bool } type historyQuery struct { Pattern *regexp.Regexp From time.Time To time.Time HasFrom bool HasTo bool } type joinLeaveEventSnapshot struct { At time.Time Kind string Name string Count int } type historyExportRequest struct { Day time.Time Selected map[string]bool Rows []historyDetailRow ExportDir string IncludeTime bool IncludeWorld bool IncludeJoinLeave bool CustomEnabled bool Custom string RegexText string Query historyQuery } type historyExportResult struct { Path string Err error Day time.Time SelectedCount int } type historyRefreshResult struct { Rows []guiWorldVisitRow CalendarCells []historyCalendarCell DetailRows []historyDetailRow DetailHeightPx int32 SelectedDate time.Time Month time.Time SelectedKeys map[string]bool Status string Regex string FromDate string ToDate string Err error Elapsed time.Duration } var historyEventsCacheMu sync.Mutex var historyEventsCache struct { jsonMod time.Time jsonSize int64 logMod time.Time logSize int64 items []historyEventRecord } func ensureHistoryState(app *guiApp) { if app == nil { return } if app.historySelectedKeys == nil { app.historySelectedKeys = map[string]bool{} } if app.historySelectedDate.IsZero() { app.historySelectedDate = time.Now() } if app.historyMonth.IsZero() { app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location()) } app.historySelectedDate = clampHistoryDate(app.historySelectedDate, historyQueryFromApp(app)) app.historyMonth = time.Date(app.historySelectedDate.Year(), app.historySelectedDate.Month(), 1, 0, 0, 0, 0, app.historySelectedDate.Location()) } func historyQueryFromApp(app *guiApp) historyQuery { q := historyQuery{} if app == nil { return q } if s := strings.TrimSpace(app.historyRegex); s != "" { if re, err := regexp.Compile(s); err == nil { q.Pattern = re } } if s := strings.TrimSpace(app.historyFromDate); s != "" { if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { q.From = t q.HasFrom = true } } if s := strings.TrimSpace(app.historyToDate); s != "" { if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { q.To = t.AddDate(0, 0, 1) q.HasTo = true } } return q } func historyInQueryRange(day time.Time, q historyQuery) bool { start, end := historyDayBounds(day) if q.HasFrom && end.Before(q.From) { return false } if q.HasTo && !start.Before(q.To) { return false } return true } func historyDayBounds(day time.Time) (time.Time, time.Time) { loc := day.Location() if loc == nil { loc = time.Local } start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) return start, start.AddDate(0, 0, 1) } func clampHistoryDate(day time.Time, q historyQuery) time.Time { if day.IsZero() { return day } start, end := historyDayBounds(day) if q.HasFrom && start.Before(q.From) { return q.From } if q.HasTo && !start.Before(q.To) { return q.To.AddDate(0, 0, -1) } if q.HasFrom && q.HasTo { if end.Before(q.From) { return q.From } } return day } func latestHistoryDate() time.Time { visits := historyVisitSource() var latest time.Time for _, v := range visits { at := v.End if at.IsZero() { at = v.Start } if at.After(latest) { latest = at } } if latest.IsZero() { if world, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(world) != "" && !since.IsZero() { return since } } return latest } func historyVisitSource() []historyVisitRecord { records := make([]historyVisitRecord, 0, len(readWorldHistory())+1) for _, visit := range readWorldHistory() { records = append(records, historyVisitRecord{ Key: historyVisitKey(visit.WorldLabel, visit.StartedAt, visit.EndedAt, visit.WorldID, visit.InstanceID, false), WorldLabel: visit.WorldLabel, WorldID: visit.WorldID, InstanceID: visit.InstanceID, Start: visit.StartedAt, End: visit.EndedAt, }) } if label, since, ok := currentWorldVisitInfo(); ok && strings.TrimSpace(label) != "" && !since.IsZero() { records = append(records, historyVisitRecord{ Key: historyVisitKey(label, since, time.Time{}, "", "", true), WorldLabel: label, Start: since, Current: true, }) } sort.SliceStable(records, func(i, j int) bool { ti := records[i].End if ti.IsZero() { ti = records[i].Start } tj := records[j].End if tj.IsZero() { tj = records[j].Start } if ti.Equal(tj) { return strings.ToLower(records[i].WorldLabel) < strings.ToLower(records[j].WorldLabel) } return ti.After(tj) }) return records } func historyVisitKey(label string, start, end time.Time, worldID, instanceID string, current bool) string { key := strings.TrimSpace(label) if worldID != "" { key = worldID if instanceID != "" { key += ":" + instanceID } } if !start.IsZero() { key += "|" + start.UTC().Format(time.RFC3339Nano) } if !end.IsZero() { key += "|" + end.UTC().Format(time.RFC3339Nano) } if current { key += "|current" } return key } func historyEventsFromSnapshot() []historyEventRecord { jsonPath := filepath.Join(runtimeDir(), "join_leave_events.json") logPath := filepath.Join(runtimeDir(), "join_leave.log") jsonInfo, jsonErr := os.Stat(jsonPath) logInfo, logErr := os.Stat(logPath) if jsonErr == nil || logErr == nil { historyEventsCacheMu.Lock() if historyEventsCache.items != nil && ((jsonErr == nil && historyEventsCache.jsonMod.Equal(jsonInfo.ModTime()) && historyEventsCache.jsonSize == jsonInfo.Size()) || jsonErr != nil) && ((logErr == nil && historyEventsCache.logMod.Equal(logInfo.ModTime()) && historyEventsCache.logSize == logInfo.Size()) || logErr != nil) { out := append([]historyEventRecord(nil), historyEventsCache.items...) historyEventsCacheMu.Unlock() return out } historyEventsCacheMu.Unlock() } if b, err := os.ReadFile(jsonPath); err == nil && len(b) > 0 { if events := decodeHistoryEventsSnapshot(b); len(events) > 0 { if jsonErr == nil { historyEventsCacheMu.Lock() historyEventsCache.jsonMod = jsonInfo.ModTime() historyEventsCache.jsonSize = jsonInfo.Size() historyEventsCache.logMod = time.Time{} historyEventsCache.logSize = 0 historyEventsCache.items = append([]historyEventRecord(nil), events...) historyEventsCacheMu.Unlock() } return events } } b, err := os.ReadFile(logPath) if err != nil || len(b) == 0 { return nil } out := make([]historyEventRecord, 0, 128) var currentAt time.Time for _, raw := range strings.Split(string(b), "\n") { line := strings.TrimSpace(strings.TrimRight(raw, "\r")) if line == "" { continue } if strings.Contains(line, "VRC JOIN/LEAVE") { if at, ok := parseRuntimeTime(line); ok { currentAt = at } continue } m := joinLeaveBodyPattern.FindStringSubmatch(line) if len(m) != 4 { continue } out = append(out, historyEventRecord{ At: currentAt, Kind: m[1], Name: strings.TrimSpace(m[2]), Raw: line, }) } if logErr == nil { historyEventsCacheMu.Lock() historyEventsCache.jsonMod = time.Time{} historyEventsCache.jsonSize = 0 historyEventsCache.logMod = logInfo.ModTime() historyEventsCache.logSize = logInfo.Size() historyEventsCache.items = append([]historyEventRecord(nil), out...) historyEventsCacheMu.Unlock() } return out } func decodeHistoryEventsSnapshot(b []byte) []historyEventRecord { type snapshot struct { UpdatedAt time.Time `json:"updated_at"` Events []struct { At time.Time `json:"at"` Kind string `json:"kind"` Name string `json:"name"` Count int `json:"count"` } `json:"events"` } var snap snapshot if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 { out := make([]historyEventRecord, 0, len(snap.Events)) for _, ev := range snap.Events { out = append(out, historyEventRecord{ At: ev.At, Kind: ev.Kind, Name: ev.Name, Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name), }) } return dedupeHistoryEventRecords(out) } var flat []struct { At time.Time `json:"at"` Kind string `json:"kind"` Name string `json:"name"` Count int `json:"count"` } if err := json.Unmarshal(b, &flat); err == nil && len(flat) > 0 { out := make([]historyEventRecord, 0, len(flat)) for _, ev := range flat { out = append(out, historyEventRecord{ At: ev.At, Kind: ev.Kind, Name: ev.Name, Raw: fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name), }) } return dedupeHistoryEventRecords(out) } return nil } func dedupeHistoryEventRecords(events []historyEventRecord) []historyEventRecord { if len(events) <= 1 { return events } seen := make(map[string]struct{}, len(events)) out := make([]historyEventRecord, 0, len(events)) for _, ev := range events { key := ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + strings.TrimSpace(ev.Name) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, ev) } return out } func historyVisitsForDay(app *guiApp, day time.Time) []historyDetailRow { q := historyQueryFromApp(app) if !historyInQueryRange(day, q) { return nil } events := historyEventsFromSnapshot() visits := historyVisitSource() return historyVisitsForDayFromData(app, day, visits, events, q) } func historyVisitsForDayFromData(app *guiApp, day time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyDetailRow { dayStart, dayEnd := historyDayBounds(day) rows := make([]historyDetailRow, 0, len(visits)) for _, visit := range visits { visitStart := visit.Start if visitStart.IsZero() { continue } visitEnd := visit.End if visitEnd.IsZero() { visitEnd = time.Now() } if !visitStart.Before(dayEnd) || !visitEnd.After(dayStart) { continue } segmentStart := visitStart if segmentStart.Before(dayStart) { segmentStart = dayStart } segmentEnd := visitEnd if segmentEnd.After(dayEnd) { segmentEnd = dayEnd } lines, users, matched := historyVisitLinesForSegment(visit.WorldLabel, segmentStart, segmentEnd, events, q) if q.Pattern != nil && !matched { continue } rows = append(rows, historyDetailRow{ Key: visit.Key, WorldLabel: visit.WorldLabel, Start: segmentStart, End: segmentEnd, Users: users, Lines: lines, Current: visit.Current, }) } sort.SliceStable(rows, func(i, j int) bool { if rows[i].Start.Equal(rows[j].Start) { return strings.ToLower(rows[i].WorldLabel) < strings.ToLower(rows[j].WorldLabel) } return rows[i].Start.Before(rows[j].Start) }) return rows } func historyVisitLinesForSegment(worldLabel string, start, end time.Time, events []historyEventRecord, q historyQuery) ([]string, []string, bool) { if end.Before(start) { end = start } lines := make([]string, 0, 16) users := make([]string, 0, 8) seenUsers := map[string]struct{}{} matched := false if q.Pattern != nil && q.Pattern.MatchString(worldLabel) { matched = true } for _, ev := range events { if ev.At.IsZero() || ev.At.Before(start) || !ev.At.Before(end) { continue } raw := fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name) if q.Pattern != nil { if !(q.Pattern.MatchString(worldLabel) || q.Pattern.MatchString(ev.Name) || q.Pattern.MatchString(raw)) { continue } } matched = true lines = append(lines, raw) if _, ok := seenUsers[ev.Name]; !ok { seenUsers[ev.Name] = struct{}{} users = append(users, ev.Name) } } if q.Pattern == nil { matched = true } sort.Strings(users) return lines, users, matched } func historyCalendarCellsForMonth(app *guiApp) []historyCalendarCell { ensureHistoryState(app) q := historyQueryFromApp(app) monthStart := app.historyMonth if monthStart.IsZero() { monthStart = time.Now() } monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) events := historyEventsFromSnapshot() visits := historyVisitSource() return historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q) } func historyCalendarCellsForMonthFromData(app *guiApp, monthStart time.Time, visits []historyVisitRecord, events []historyEventRecord, q historyQuery) []historyCalendarCell { counts := map[string]int{} daysInMonth := monthStart.AddDate(0, 1, -1).Day() if q.Pattern == nil { monthEnd := monthStart.AddDate(0, 1, 0) for _, visit := range visits { visitStart := visit.Start if visitStart.IsZero() { continue } visitEnd := visit.End if visitEnd.IsZero() { visitEnd = time.Now() } if !visitStart.Before(monthEnd) || !visitEnd.After(monthStart) { continue } dayCursor := time.Date(visitStart.Year(), visitStart.Month(), visitStart.Day(), 0, 0, 0, 0, monthStart.Location()) if dayCursor.Before(monthStart) { dayCursor = monthStart } for dayCursor.Before(monthEnd) { dayStart, dayEnd := historyDayBounds(dayCursor) if visitStart.Before(dayEnd) && visitEnd.After(dayStart) { counts[dayCursor.Format("2006-01-02")]++ } dayCursor = dayCursor.AddDate(0, 0, 1) } } } else { for day := 1; day <= daysInMonth; day++ { date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) counts[date.Format("2006-01-02")] = len(historyVisitsForDayFromData(app, date, visits, events, q)) } } firstWeekday := int(monthStart.Weekday()) out := make([]historyCalendarCell, 0, 42) for i := 0; i < firstWeekday; i++ { out = append(out, historyCalendarCell{}) } for day := 1; day <= daysInMonth; day++ { date := time.Date(monthStart.Year(), monthStart.Month(), day, 0, 0, 0, 0, monthStart.Location()) cell := historyCalendarCell{ Date: date, Count: counts[date.Format("2006-01-02")], InRange: historyInQueryRange(date, q), } if sameDay(date, app.historySelectedDate) { cell.Selected = true } out = append(out, cell) } for len(out)%7 != 0 { out = append(out, historyCalendarCell{}) } return out } func sameDay(a, b time.Time) bool { if a.IsZero() || b.IsZero() { return false } ay, am, ad := a.Date() by, bm, bd := b.Date() return ay == by && am == bm && ad == bd } func exportHistoryDay(app *guiApp, day time.Time, selected map[string]bool) (string, error) { req := historyExportRequest{Day: day, Query: historyQueryFromApp(app)} if app != nil { req.Selected = cloneHistorySelection(selected) req.Rows = cloneHistoryDetailRows(app.historyDetailRows) req.ExportDir = normalizeHistoryExportDir(app.historyExportDir) req.IncludeTime = app.historyExportIncludeTime req.IncludeWorld = app.historyExportIncludeWorld req.IncludeJoinLeave = app.historyExportIncludeJoinLeave req.CustomEnabled = app.historyExportCustomEnabled req.Custom = strings.TrimSpace(app.historyExportCustom) req.RegexText = strings.TrimSpace(app.historyRegex) } return exportHistoryDayFromRequest(req) } func exportHistoryDayFromRequest(req historyExportRequest) (string, error) { events := historyEventsFromSnapshot() visits := historyVisitSource() rows := append([]historyDetailRow(nil), req.Rows...) if len(rows) == 0 { rows = historyVisitsForDayFromData(nil, req.Day, visits, events, req.Query) } if len(req.Selected) > 0 { filtered := rows[:0] for _, row := range rows { if req.Selected[row.Key] { filtered = append(filtered, row) } } rows = append([]historyDetailRow(nil), filtered...) } customPattern := (*regexp.Regexp)(nil) if req.CustomEnabled { if s := strings.TrimSpace(req.Custom); s != "" { if re, err := regexp.Compile(s); err == nil { customPattern = re } } } var b strings.Builder b.WriteString("Date: ") b.WriteString(req.Day.Format("2006-01-02")) b.WriteString("\n") if len(req.Selected) > 0 { b.WriteString("Selection: ") b.WriteString(fmt.Sprintf("%d rows", len(req.Selected))) b.WriteString("\n") } if req.Query.Pattern != nil { b.WriteString("Filter: ") if strings.TrimSpace(req.RegexText) != "" { b.WriteString(strings.TrimSpace(req.RegexText)) } else { b.WriteString(req.Query.Pattern.String()) } b.WriteString("\n") } if req.CustomEnabled && customPattern != nil { b.WriteString("Custom: ") b.WriteString(strings.TrimSpace(req.Custom)) b.WriteString("\n") } b.WriteString("\n") if len(rows) == 0 { if len(req.Selected) > 0 { b.WriteString("(no selected visits)\n") } else { b.WriteString("(no visits)\n") } } for _, row := range rows { rowLines := append([]string(nil), row.Lines...) if customPattern != nil { filtered := rowLines[:0] for _, line := range rowLines { if customPattern.MatchString(line) || customPattern.MatchString(row.WorldLabel) || customPattern.MatchString(strings.Join(row.Users, " ")) { filtered = append(filtered, line) } } rowLines = append([]string(nil), filtered...) } if req.IncludeWorld { b.WriteString("\nWorld: ") b.WriteString(strings.TrimSpace(row.WorldLabel)) b.WriteString("\n") } if req.IncludeTime { b.WriteString("Time: ") b.WriteString(historyTimeRangeLabel(row.Start, row.End, row.Current)) b.WriteString("\n") } if len(row.Users) > 0 { b.WriteString("Users: ") b.WriteString(strings.Join(row.Users, ", ")) b.WriteString("\n") } if req.IncludeJoinLeave { if len(rowLines) == 0 { b.WriteString("(no matches)\n") } for _, line := range rowLines { b.WriteString(line) b.WriteString("\n") } } } appendExportDayVisits(&b, req.Day, visits, req.Query) appendExportDayEvents(&b, req.Day, events, req.Query) appendExportGuestSnapshot(&b, req.Day, req.Query) exportDir := runtimeDir() if strings.TrimSpace(req.ExportDir) != "" { exportDir = normalizeHistoryExportDir(req.ExportDir) if !filepath.IsAbs(exportDir) { exportDir = filepath.Join(runtimeDir(), exportDir) } } path := filepath.Join(exportDir, "visit_"+req.Day.Format("20060102")+".log") if err := os.MkdirAll(exportDir, 0o755); err != nil { return "", err } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { return "", err } return path, nil } func appendExportDayVisits(b *strings.Builder, day time.Time, visits []historyVisitRecord, q historyQuery) { if b == nil { return } dayStart, dayEnd := historyDayBounds(day) matched := make([]historyVisitRecord, 0, len(visits)) for _, visit := range visits { start := visit.Start if start.IsZero() { continue } end := visit.End if end.IsZero() { end = time.Now() } if !start.Before(dayEnd) || !end.After(dayStart) { continue } if q.Pattern != nil && !q.Pattern.MatchString(visit.WorldLabel) && !q.Pattern.MatchString(visit.WorldID) && !q.Pattern.MatchString(visit.InstanceID) { continue } matched = append(matched, visit) } sort.SliceStable(matched, func(i, j int) bool { return matched[i].Start.Before(matched[j].Start) }) b.WriteString("\nAll visits:\n") if len(matched) == 0 { b.WriteString("(no visits)\n") return } for _, visit := range matched { b.WriteString("- ") b.WriteString(historyTimeRangeLabel(visit.Start, visit.End, visit.Current)) b.WriteString(" ") b.WriteString(strings.TrimSpace(visit.WorldLabel)) if strings.TrimSpace(visit.WorldID) != "" { b.WriteString(" world_id=") b.WriteString(strings.TrimSpace(visit.WorldID)) } if strings.TrimSpace(visit.InstanceID) != "" { b.WriteString(" instance_id=") b.WriteString(strings.TrimSpace(visit.InstanceID)) } b.WriteString("\n") } } func appendExportDayEvents(b *strings.Builder, day time.Time, events []historyEventRecord, q historyQuery) { if b == nil { return } dayStart, dayEnd := historyDayBounds(day) matched := make([]historyEventRecord, 0, len(events)) for _, ev := range events { if ev.At.IsZero() || ev.At.Before(dayStart) || !ev.At.Before(dayEnd) { continue } raw := fmt.Sprintf("[%s] %s %s", ev.At.Format("15:04"), ev.Kind, ev.Name) if q.Pattern != nil && !q.Pattern.MatchString(ev.Name) && !q.Pattern.MatchString(raw) { continue } matched = append(matched, ev) } sort.SliceStable(matched, func(i, j int) bool { if matched[i].At.Equal(matched[j].At) { return strings.ToLower(matched[i].Name) < strings.ToLower(matched[j].Name) } return matched[i].At.Before(matched[j].At) }) b.WriteString("\nAll join/leave events:\n") if len(matched) == 0 { b.WriteString("(no events)\n") return } for _, ev := range matched { b.WriteString("- ") b.WriteString(ev.At.Format("15:04")) b.WriteString(" ") b.WriteString(ev.Kind) b.WriteString(" ") b.WriteString(strings.TrimSpace(ev.Name)) if ev.Raw != "" { b.WriteString(" | ") b.WriteString(strings.TrimSpace(ev.Raw)) } b.WriteString("\n") } } func appendExportGuestSnapshot(b *strings.Builder, day time.Time, q historyQuery) { if b == nil { return } guests := readGuestSnapshot() dayStart, dayEnd := historyDayBounds(day) type guestLine struct { name string present bool join time.Time leave time.Time } lines := make([]guestLine, 0, len(guests)) for _, guest := range guests { name := strings.TrimSpace(guest.Name) if name == "" { continue } inDay := guest.Present || (!guest.LastJoin.IsZero() && !guest.LastJoin.Before(dayStart) && guest.LastJoin.Before(dayEnd)) || (!guest.LastLeave.IsZero() && !guest.LastLeave.Before(dayStart) && guest.LastLeave.Before(dayEnd)) if !inDay { continue } if q.Pattern != nil && !q.Pattern.MatchString(name) { continue } lines = append(lines, guestLine{name: name, present: guest.Present, join: guest.LastJoin, leave: guest.LastLeave}) } sort.SliceStable(lines, func(i, j int) bool { return strings.ToLower(lines[i].name) < strings.ToLower(lines[j].name) }) b.WriteString("\nGuest snapshot:\n") if len(lines) == 0 { b.WriteString("(no guests)\n") return } for _, line := range lines { b.WriteString("- ") if line.present { b.WriteString("present ") } else { b.WriteString("left ") } b.WriteString(line.name) if !line.join.IsZero() { b.WriteString(" join=") b.WriteString(line.join.Format("15:04")) } if !line.leave.IsZero() { b.WriteString(" leave=") b.WriteString(line.leave.Format("15:04")) } b.WriteString("\n") } } func cloneHistorySelection(selected map[string]bool) map[string]bool { if len(selected) == 0 { return nil } out := make(map[string]bool, len(selected)) for key, ok := range selected { if ok { out[key] = true } } return out } func cloneHistoryDetailRows(rows []historyDetailRow) []historyDetailRow { if len(rows) == 0 { return nil } out := make([]historyDetailRow, 0, len(rows)) for _, row := range rows { cloned := row if len(row.Users) > 0 { cloned.Users = append([]string(nil), row.Users...) } if len(row.Lines) > 0 { cloned.Lines = append([]string(nil), row.Lines...) } out = append(out, cloned) } return out } func refreshHistoryCache(app *guiApp, force bool) bool { if app == nil { return false } ensureHistoryState(app) if !force && app.historyCacheLoaded && !app.historyReloadPending { return false } events := historyEventsFromSnapshot() visits := historyVisitSource() q := historyQueryFromApp(app) app.historyRows = historyRows() if app.historyRows == nil { app.historyRows = []guiWorldVisitRow{} } monthStart := app.historyMonth if monthStart.IsZero() { monthStart = time.Now() } monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) app.historyCalendarCells = historyCalendarCellsForMonthFromData(app, monthStart, visits, events, q) if app.historyCalendarCells == nil { app.historyCalendarCells = []historyCalendarCell{} } app.historyDetailRows = historyVisitsForDayFromData(app, app.historySelectedDate, visits, events, q) if app.historyDetailRows == nil { app.historyDetailRows = []historyDetailRow{} } height := int32(132) for _, row := range app.historyDetailRows { height += historyDetailRowHeight(row) + 6 } if height < 120 { height = 120 } app.historyDetailContentHeightPx = height app.historyReloadPending = false app.historyCacheLoaded = true app.historyStatus = "reloaded: " + time.Now().Format("15:04:05") return true } func startHistoryRefresh(app *guiApp, force bool) bool { if app == nil || app.historyRefreshRunning { return false } if !force && app.historyCacheLoaded && !app.historyReloadPending { return false } ensureHistoryState(app) if app.historyRefreshResultCh == nil { app.historyRefreshResultCh = make(chan historyRefreshResult, 1) } work := *app work.historySelectedKeys = cloneHistorySelection(app.historySelectedKeys) work.historyRefreshResultCh = nil work.historyExportResultCh = nil app.historyRefreshRunning = true app.historyReloadPending = true if strings.TrimSpace(app.historyStatus) == "" { app.historyStatus = "reloading..." } hwnd := app.hwnd go func(ch chan<- historyRefreshResult, work guiApp, hwnd uintptr) { start := time.Now() result := historyRefreshResult{ SelectedDate: work.historySelectedDate, Month: work.historyMonth, SelectedKeys: cloneHistorySelection(work.historySelectedKeys), Regex: work.historyRegex, FromDate: work.historyFromDate, ToDate: work.historyToDate, } defer func() { result.Elapsed = time.Since(start) if r := recover(); r != nil { result.Err = fmt.Errorf("refresh panic: %v", r) } select { case ch <- result: default: } if postMessageProc != nil && hwnd != 0 { postMessageProc.Call(hwnd, uintptr(wmHistoryRefreshDone), 0, 0) } }() refreshHistoryCache(&work, true) result.Rows = append([]guiWorldVisitRow(nil), work.historyRows...) result.CalendarCells = append([]historyCalendarCell(nil), work.historyCalendarCells...) result.DetailRows = cloneHistoryDetailRows(work.historyDetailRows) result.DetailHeightPx = work.historyDetailContentHeightPx result.SelectedDate = work.historySelectedDate result.Month = work.historyMonth result.SelectedKeys = cloneHistorySelection(work.historySelectedKeys) result.Status = work.historyStatus }(app.historyRefreshResultCh, work, hwnd) guiLog("history refresh start") return true } func drainHistoryRefreshResult(app *guiApp) bool { if app == nil || app.historyRefreshResultCh == nil || !app.historyRefreshRunning { return false } handled := false for { select { case res := <-app.historyRefreshResultCh: handled = true app.historyRefreshRunning = false if res.Err != nil { app.historyStatus = "reload failed: " + res.Err.Error() app.historyReloadPending = true guiLog(fmt.Sprintf("history refresh failed elapsed=%s err=%v", res.Elapsed, res.Err)) invalidateHistoryPanes(app) continue } stale := !sameDay(app.historySelectedDate, res.SelectedDate) || !sameHistoryMonth(app.historyMonth, res.Month) || app.historyRegex != res.Regex || app.historyFromDate != res.FromDate || app.historyToDate != res.ToDate if stale { app.historyReloadPending = true guiLog("history refresh stale; scheduling another refresh") invalidateHistoryPanes(app) continue } app.historyRows = res.Rows app.historyCalendarCells = res.CalendarCells app.historyDetailRows = res.DetailRows app.historyDetailContentHeightPx = res.DetailHeightPx app.historySelectedDate = res.SelectedDate app.historyMonth = res.Month app.historySelectedKeys = res.SelectedKeys if app.historySelectedKeys == nil { app.historySelectedKeys = map[string]bool{} } app.historyReloadPending = false app.historyCacheLoaded = true if strings.TrimSpace(res.Status) != "" { app.historyStatus = res.Status } else { app.historyStatus = "reloaded: " + time.Now().Format("15:04:05") } guiLog(fmt.Sprintf("history refresh ok elapsed=%s rows=%d details=%d", res.Elapsed, len(res.Rows), len(res.DetailRows))) invalidateHistoryPanes(app) default: return handled } } } func sameHistoryMonth(a, b time.Time) bool { if a.IsZero() || b.IsZero() { return a.IsZero() && b.IsZero() } return a.Year() == b.Year() && a.Month() == b.Month() } func requestHistoryRefresh(app *guiApp, reason string) { if app == nil { return } app.historyReloadPending = true if strings.TrimSpace(reason) != "" { app.historyStatus = reason } } func paintHistoryCalendarPane(hwnd uintptr, app *guiApp) uintptr { if app == nil { return 0 } ensureHistoryState(app) hdc, done := beginPanePaint(hwnd) if hdc == 0 { return 0 } defer done() var rc winRect getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) fillSolid(hdc, rc, clrPaneBg) drawPaneText(hdc, 18, 18, "履歴カレンダー", app.joinBoldHFont, clrAccentGreen) monthLabel := app.historyMonth.Format("2006年01月") drawPaneText(hdc, 18, 42, monthLabel, app.hFont, clrText) app.historyPrevMonthRect = winRect{Left: rc.Right - 108, Top: 14, Right: rc.Right - 58, Bottom: 42} app.historyNextMonthRect = winRect{Left: rc.Right - 54, Top: 14, Right: rc.Right - 14, Bottom: 42} drawSettingsBox(hdc, app.historyPrevMonthRect, 0x0038281a, clrPaneBorder) drawSettingsBox(hdc, app.historyNextMonthRect, 0x0038281a, clrPaneBorder) drawCenteredPaneText(hdc, app.historyPrevMonthRect, "<", app.hFont, clrText) drawCenteredPaneText(hdc, app.historyNextMonthRect, ">", app.hFont, clrText) weekdays := []string{"日", "月", "火", "水", "木", "金", "土"} cellW := (rc.Right - 24) / 7 if cellW < 48 { cellW = 48 } cellH := int32(56) startX := int32(12) startY := int32(102) for i, wd := range weekdays { x := startX + int32(i)*cellW drawPaneText(hdc, x+6, startY-18, wd, app.hFont, clrMutedText) } cells := app.historyCalendarCells if len(cells) == 0 { drawPaneText(hdc, 24, 118, "履歴カレンダーを読み込み中", app.joinBoldHFont, clrMutedText) drawPaneText(hdc, 24, 146, "しばらくしても出ない場合は再読み込みを押してください", app.hFont, clrMutedText) } for i, cell := range cells { if cell.Date.IsZero() { continue } col := i % 7 row := i / 7 rect := winRect{ Left: startX + int32(col)*cellW, Top: startY + int32(row)*cellH, Right: startX + int32(col+1)*cellW - 4, Bottom: startY + int32(row+1)*cellH - 4, } cell.Rect = rect app.historyCalendarCells[i].Rect = rect fill := uint32(0x00261a11) border := uint32(clrPaneBorder) textColor := uint32(clrText) if !cell.InRange { fill = 0x001b140d textColor = uint32(clrMutedText) } if cell.Count > 0 { fill = 0x00321f12 border = clrAccentGreen } if cell.Selected { fill = 0x003d2a14 border = clrAccentBlue } drawSettingsBox(hdc, rect, fill, border) drawPaneText(hdc, rect.Left+6, rect.Top+6, fmt.Sprintf("%d", cell.Date.Day()), app.hFont, textColor) if cell.Count > 0 { drawPaneText(hdc, rect.Right-24, rect.Bottom-18, fmt.Sprintf("%d", cell.Count), app.hFont, clrAccentGreen) } } if app.historyStatus != "" { drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText) } return 0 } func paintHistoryDetailPane(hwnd uintptr, app *guiApp) uintptr { if app == nil { return 0 } start := time.Now() defer func() { if d := time.Since(start); d > 80*time.Millisecond { guiLog(fmt.Sprintf("paintHistoryDetailPane slow=%s rows=%d scroll=%d", d, len(app.historyDetailRows), app.historyScrollPos)) } }() ensureHistoryState(app) hdc, done := beginPanePaint(hwnd) if hdc == 0 { return 0 } defer done() var rc winRect getClientRectProc.Call(hwnd, uintptr(unsafe.Pointer(&rc))) fillSolid(hdc, rc, clrPaneBgAlt) rows := app.historyDetailRows title := app.historySelectedDate.Format("2006年01月02日") drawPaneText(hdc, 18, 52, title, app.joinBoldHFont, clrAccentGreen) summary := fmt.Sprintf("%d件", len(rows)) drawPaneText(hdc, 18, 78, summary, app.hFont, clrMutedText) if strings.TrimSpace(app.historyRegex) != "" { drawPaneText(hdc, 96, 78, "grep: "+app.historyRegex, app.hFont, clrMutedText) } if strings.TrimSpace(app.historyFromDate) != "" || strings.TrimSpace(app.historyToDate) != "" { rangeLabel := strings.TrimSpace(app.historyFromDate) if rangeLabel == "" { rangeLabel = "..." } rangeLabel += " - " if to := strings.TrimSpace(app.historyToDate); to != "" { rangeLabel += to } else { rangeLabel += "..." } drawPaneText(hdc, 260, 78, rangeLabel, app.hFont, clrMutedText) } app.historyReloadRect = winRect{Left: rc.Right - 256, Top: 10, Right: rc.Right - 140, Bottom: 38} drawSettingsBox(hdc, app.historyReloadRect, 0x0038281a, clrPaneBorder) drawCenteredPaneText(hdc, app.historyReloadRect, "Reload", app.hFont, clrText) app.historyExportRect = winRect{Left: rc.Right - 132, Top: 10, Right: rc.Right - 16, Bottom: 38} drawSettingsBox(hdc, app.historyExportRect, 0x0038281a, clrAccentGreen) drawCenteredPaneText(hdc, app.historyExportRect, "Export", app.hFont, clrText) if app.historyStatus != "" { drawPaneText(hdc, 18, rc.Bottom-26, app.historyStatus, app.hFont, clrMutedText) } pageHeight := rc.Bottom - rc.Top contentHeight := historyDetailContentHeight(hwnd, app) maxPos := scrollMax(contentHeight, pageHeight) if app.historyScrollPos > maxPos { app.historyScrollPos = maxPos } applyPaneScroll(hwnd, app.historyScrollPos, contentHeight, pageHeight) y := int32(126 - app.historyScrollPos) for i, row := range rows { rowHeight := historyDetailRowHeight(row) row.Rect = winRect{Left: 14, Top: y, Right: rc.Right - 14, Bottom: y + rowHeight - 4} app.historyDetailRows[i].Rect = row.Rect if row.Rect.Bottom < 72 { y += rowHeight continue } if row.Rect.Top > rc.Bottom-40 { break } selected := app.historySelectedKeys != nil && app.historySelectedKeys[row.Key] fill := uint32(0x00271b12) border := uint32(clrPaneBorder) if selected { fill = 0x00331f11 border = clrAccentBlue } else if row.Current { border = clrAccentGreen } drawSettingsBox(hdc, row.Rect, fill, border) drawPaneText(hdc, row.Rect.Left+12, y+6, "入室 "+historyEntryTimeLabel(row.Start, false, row.Current), app.hFont, clrMutedText) drawPaneText(hdc, row.Rect.Left+146, y+6, "退室 "+historyEntryTimeLabel(row.End, true, row.Current), app.hFont, clrMutedText) drawPaneText(hdc, row.Rect.Right-110, y+6, humanDurationLabel(row.Start, row.End, row.Current), app.hFont, clrMutedText) label := ellipsizeTextToWidth(hdc, row.WorldLabel, row.Rect.Right-row.Rect.Left-150, app.joinBoldHFont) if label == "" { label = "(unknown)" } drawPaneText(hdc, row.Rect.Left+12, y+24, label, app.joinBoldHFont, clrText) if len(row.Users) > 0 { users := "Users: " + strings.Join(row.Users, ", ") drawPaneText(hdc, row.Rect.Left+12, y+44, ellipsizeTextToWidth(hdc, users, row.Rect.Right-row.Rect.Left-24, app.hFont), app.hFont, clrMutedText) } lineY := y + 62 showLines := row.Lines if len(showLines) > 3 { showLines = append([]string(nil), showLines[:3]...) showLines = append(showLines, fmt.Sprintf("... +%d more", len(row.Lines)-3)) } for _, line := range showLines { drawPaneText(hdc, row.Rect.Left+18, lineY, ellipsizeTextToWidth(hdc, line, row.Rect.Right-row.Rect.Left-40, app.hFont), app.hFont, clrText) lineY += 18 } y += rowHeight } return 0 } func historyDetailContentHeight(hwnd uintptr, app *guiApp) int32 { if app == nil || getClientRectProc == nil { return 0 } if app.historyDetailContentHeightPx > 0 { return app.historyDetailContentHeightPx } rows := app.historyDetailRows height := int32(132) for _, row := range rows { height += historyDetailRowHeight(row) + 6 } if height < 120 { height = 120 } app.historyDetailContentHeightPx = height return height } func historyDetailRowHeight(row historyDetailRow) int32 { lines := len(row.Lines) if lines > 3 { lines = 4 } height := int32(72 + lines*18) if len(row.Users) > 0 { height += 18 } if height < 84 { height = 84 } if height > 160 { height = 160 } return height } func historyTimeRangeLabel(start, end time.Time, current bool) string { if start.IsZero() { return "" } if end.IsZero() { end = time.Now() } if end.Before(start) { end = start } startLabel := start.Format("15:04") endLabel := end.Format("15:04") if !sameDay(start, end) { startLabel = start.Format("01/02 15:04") if !current && isMidnight(end) { endLabel = "23:59" } else { endLabel = end.Format("01/02 15:04") } } else if !current && isMidnight(end) { endLabel = "23:59" } if current { return startLabel + " - now" } return startLabel + " - " + endLabel } func isMidnight(t time.Time) bool { return t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 } func historyEntryTimeLabel(t time.Time, isEnd bool, current bool) string { if t.IsZero() { if current { return "now" } return "" } if current { return "now" } if isEnd && isMidnight(t) { return "23:59" } return t.Format("15:04") } func handleHistoryPaneClick(app *guiApp, hwnd uintptr, x, y int32, getWindowText, setWindowText *syscall.LazyProc) bool { if app == nil { return false } ensureHistoryState(app) switch hwnd { case app.leftHwnd: if pointInRect(x, y, app.historyPrevMonthRect) { historyShiftMonth(app, -1) return true } if pointInRect(x, y, app.historyNextMonthRect) { historyShiftMonth(app, 1) return true } for _, cell := range app.historyCalendarCells { if cell.Date.IsZero() || !pointInRect(x, y, cell.Rect) { continue } historySelectDate(app, cell.Date) return true } case app.rightHwnd: if pointInRect(x, y, app.historyReloadRect) { requestHistoryRefresh(app, "reloading...") invalidateHistoryPanes(app) return true } if pointInRect(x, y, app.historyExportRect) { guiLog(fmt.Sprintf("history export click day=%s status=%q", app.historySelectedDate.Format("2006-01-02"), app.historyStatus)) startHistoryExport(app, getWindowText, setWindowText) return true } for _, row := range app.historyDetailRows { if row.Key == "" || !pointInRect(x, y, row.Rect) { continue } if app.historySelectedKeys == nil { app.historySelectedKeys = map[string]bool{} } if app.historySelectedKeys[row.Key] { delete(app.historySelectedKeys, row.Key) } else { app.historySelectedKeys[row.Key] = true } invalidateHistoryPanes(app) return true } } return false } func historyShiftMonth(app *guiApp, delta int) { if app == nil || delta == 0 { return } ensureHistoryState(app) base := app.historyMonth if base.IsZero() { base = app.historySelectedDate } if base.IsZero() { base = time.Now() } nextMonth := time.Date(base.Year(), base.Month(), 1, 0, 0, 0, 0, base.Location()).AddDate(0, delta, 0) if nextMonth.IsZero() { return } day := app.historySelectedDate.Day() lastDay := nextMonth.AddDate(0, 1, -1).Day() if day > lastDay { day = lastDay } nextDate := time.Date(nextMonth.Year(), nextMonth.Month(), day, 0, 0, 0, 0, nextMonth.Location()) historySelectDate(app, nextDate) invalidateHistoryPanes(app) } func historySelectDate(app *guiApp, day time.Time) { if app == nil || day.IsZero() { return } ensureHistoryState(app) q := historyQueryFromApp(app) day = clampHistoryDate(day, q) if day.IsZero() { return } app.historySelectedDate = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) app.historyMonth = time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) app.historySelectedKeys = map[string]bool{} app.historyStatus = "" app.historyScrollPos = 0 requestHistoryRefresh(app, "reloading...") invalidateHistoryPanes(app) } func invalidateHistoryPanes(app *guiApp) { if invalidateRectProc == nil { return } if app != nil { if app.leftHwnd != 0 { invalidateRectProc.Call(app.leftHwnd, 0, 1) } if app.rightHwnd != 0 { invalidateRectProc.Call(app.rightHwnd, 0, 1) } } }