f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
653 lines
17 KiB
Go
653 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/insight"
|
|
"go.kenn.io/agentsview/internal/timeutil"
|
|
)
|
|
|
|
var validInsightTypes = map[string]bool{
|
|
"daily_activity": true,
|
|
"agent_analysis": true,
|
|
insight.CannedType: true,
|
|
}
|
|
|
|
type generateInsightRequest struct {
|
|
Type string `json:"type"`
|
|
DateFrom string `json:"date_from"`
|
|
DateTo string `json:"date_to"`
|
|
Project string `json:"project,omitempty"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
Kind string `json:"kind,omitempty"`
|
|
LLMOptIn bool `json:"llm_opt_in,omitempty"`
|
|
ForceRefresh bool `json:"force_refresh,omitempty"`
|
|
AutomatedScope string `json:"automated_scope,omitempty"`
|
|
Filters *insight.CannedSessionFilters `json:"filters,omitempty"`
|
|
// Timezone is the IANA zone the caller's date range is expressed in, so
|
|
// the attached activity summary covers the same local-day window as the
|
|
// activity dashboard the dates were derived from. Empty means UTC.
|
|
Timezone string `json:"timezone,omitempty"`
|
|
}
|
|
|
|
func normalizeInsightAutomatedScope(scope string) (string, bool) {
|
|
switch strings.TrimSpace(scope) {
|
|
case "", "human":
|
|
return "human", true
|
|
case "all", "automated":
|
|
return strings.TrimSpace(scope), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func normalizeCannedSessionFilters(
|
|
req generateInsightRequest,
|
|
) (insight.CannedSessionFilters, string, bool) {
|
|
requestTimezone := strings.TrimSpace(req.Timezone)
|
|
filters := insight.CannedSessionFilters{
|
|
Timezone: requestTimezone,
|
|
AutomatedScope: req.AutomatedScope,
|
|
}
|
|
if req.Filters != nil {
|
|
filters = *req.Filters
|
|
}
|
|
filters.Timezone = strings.TrimSpace(filters.Timezone)
|
|
if filters.Timezone == "" {
|
|
filters.Timezone = requestTimezone
|
|
}
|
|
if filters.Timezone == "" {
|
|
filters.Timezone = "UTC"
|
|
}
|
|
if _, err := time.LoadLocation(filters.Timezone); err != nil {
|
|
return insight.CannedSessionFilters{},
|
|
"invalid timezone: " + filters.Timezone, false
|
|
}
|
|
filters.Machine = strings.TrimSpace(filters.Machine)
|
|
filters.Agent = strings.TrimSpace(filters.Agent)
|
|
filters.Termination = strings.TrimSpace(filters.Termination)
|
|
filters.ActiveSince = strings.TrimSpace(filters.ActiveSince)
|
|
if filters.MinUserMessages < 0 {
|
|
return insight.CannedSessionFilters{},
|
|
"filters.min_user_messages must be >= 0", false
|
|
}
|
|
if filters.ActiveSince != "" &&
|
|
!timeutil.IsValidTimestamp(filters.ActiveSince) {
|
|
return insight.CannedSessionFilters{},
|
|
"filters.active_since must be RFC3339 timestamp", false
|
|
}
|
|
scopeInput := filters.AutomatedScope
|
|
if strings.TrimSpace(scopeInput) == "" {
|
|
scopeInput = req.AutomatedScope
|
|
}
|
|
scope, ok := normalizeInsightAutomatedScope(scopeInput)
|
|
if !ok {
|
|
return insight.CannedSessionFilters{},
|
|
"filters.automated_scope must be human, all, or automated",
|
|
false
|
|
}
|
|
filters.AutomatedScope = scope
|
|
return filters, "", true
|
|
}
|
|
|
|
func insightGenerateClientMessage(
|
|
agent string, err error,
|
|
) string {
|
|
if err == nil {
|
|
return fmt.Sprintf("%s generation failed", agent)
|
|
}
|
|
msg := err.Error()
|
|
// Strip stderr dump after newline for the short client message; full details
|
|
// are in the log stream.
|
|
if idx := strings.Index(msg, "\nstderr:"); idx > 0 {
|
|
msg = msg[:idx]
|
|
}
|
|
if idx := strings.Index(msg, "\nraw:"); idx > 0 {
|
|
msg = msg[:idx]
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func (s *Server) humaGenerateCannedInsight(
|
|
req generateInsightRequest,
|
|
) (*huma.StreamResponse, error) {
|
|
req.Prompt = strings.TrimSpace(req.Prompt)
|
|
kind := insight.CannedKind(req.Kind)
|
|
if !insight.ValidCannedKinds[kind] {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"invalid kind: unsupported canned insight")
|
|
}
|
|
if req.Type != "" && req.Type != insight.CannedType {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"type must be llm_canned for canned insights")
|
|
}
|
|
if !req.LLMOptIn {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"llm_opt_in must be true for canned insights")
|
|
}
|
|
if len([]rune(req.Prompt)) > insight.MaxCannedFocusRunes {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"prompt is too long for canned insight focus")
|
|
}
|
|
if !timeutil.IsValidDate(req.DateFrom) {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"invalid date_from: use YYYY-MM-DD")
|
|
}
|
|
if !timeutil.IsValidDate(req.DateTo) {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"invalid date_to: use YYYY-MM-DD")
|
|
}
|
|
if req.DateTo < req.DateFrom {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"date_to must be >= date_from")
|
|
}
|
|
if req.Agent == "" {
|
|
req.Agent = "claude"
|
|
}
|
|
if !insight.ValidAgents[req.Agent] {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"invalid agent: must be one of "+
|
|
strings.Join(insight.ValidAgentNames, ", "))
|
|
}
|
|
filters, message, ok := normalizeCannedSessionFilters(req)
|
|
if !ok {
|
|
return nil, apiError(http.StatusBadRequest, message)
|
|
}
|
|
req.Filters = &filters
|
|
req.AutomatedScope = filters.AutomatedScope
|
|
|
|
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
|
stream, ok := newHumaSSEStream(hctx)
|
|
if !ok {
|
|
writeHumaJSON(hctx, http.StatusInternalServerError,
|
|
apiErrorResponse{Message: "streaming not supported"})
|
|
return
|
|
}
|
|
s.generateCannedInsight(hctx.Context(), stream, kind, req)
|
|
}}, nil
|
|
}
|
|
|
|
func (s *Server) generateCannedInsight(
|
|
ctx context.Context,
|
|
stream *SSEStream,
|
|
kind insight.CannedKind,
|
|
req generateInsightRequest,
|
|
) {
|
|
sendJSON := func(event string, v any) bool {
|
|
return stream.SendJSON(event, v)
|
|
}
|
|
status := func(phase string) bool {
|
|
return sendJSON("status", map[string]string{
|
|
"phase": phase,
|
|
})
|
|
}
|
|
|
|
if !status("building_payload") {
|
|
return
|
|
}
|
|
payload, aggregateHash, cacheKey, err := s.buildCannedPayload(
|
|
ctx, kind, req,
|
|
)
|
|
if err != nil {
|
|
log.Printf("canned insight payload error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to build canned insight payload",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !req.ForceRefresh {
|
|
cached, err := s.db.GetCachedInsight(ctx, cacheKey)
|
|
if err != nil {
|
|
log.Printf("canned insight cache lookup error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to check insight cache",
|
|
})
|
|
return
|
|
}
|
|
if cached != nil {
|
|
markInsightCacheHit(cached)
|
|
if !status("cache_hit") {
|
|
return
|
|
}
|
|
sendJSON("done", cached)
|
|
return
|
|
}
|
|
}
|
|
|
|
prompt, err := insight.BuildCannedPrompt(payload, aggregateHash)
|
|
if err != nil {
|
|
log.Printf("canned insight prompt error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to build canned prompt",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !status("generating") {
|
|
return
|
|
}
|
|
genCtx, cancel := context.WithTimeout(
|
|
ctx, 3*time.Minute,
|
|
)
|
|
defer cancel()
|
|
result, err := s.generateStreamFunc(
|
|
genCtx, req.Agent, prompt, nil,
|
|
)
|
|
if err != nil {
|
|
log.Printf("canned insight generate error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": insightGenerateClientMessage(
|
|
req.Agent, err,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
|
|
if !status("validating") {
|
|
return
|
|
}
|
|
envelope, err := insight.ParseCannedEnvelope(result.Content)
|
|
if err != nil {
|
|
log.Printf("canned insight parse error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "generated insight was not valid JSON",
|
|
})
|
|
return
|
|
}
|
|
if err := insight.ValidateCannedEnvelope(envelope, payload); err != nil {
|
|
log.Printf("canned insight validation error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": fmt.Sprintf(
|
|
"generated insight failed validation: %v", err,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
|
|
if !status("saving") {
|
|
return
|
|
}
|
|
model := result.Model
|
|
prov, err := insight.NewCannedProvenance(
|
|
payload, aggregateHash, cacheKey, "fresh",
|
|
result.Agent, model, time.Now(),
|
|
)
|
|
if err != nil {
|
|
log.Printf("canned insight provenance error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to build insight provenance",
|
|
})
|
|
return
|
|
}
|
|
provJSON, err := json.Marshal(prov)
|
|
if err != nil {
|
|
log.Printf("canned insight provenance JSON error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to encode insight provenance",
|
|
})
|
|
return
|
|
}
|
|
structuredJSON, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
log.Printf("canned insight structured JSON error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to encode generated insight",
|
|
})
|
|
return
|
|
}
|
|
|
|
var project *string
|
|
if req.Project != "" {
|
|
project = &req.Project
|
|
}
|
|
var modelPtr *string
|
|
if model != "" {
|
|
modelPtr = &model
|
|
}
|
|
var promptPtr *string
|
|
if req.Prompt != "" {
|
|
promptPtr = &req.Prompt
|
|
}
|
|
|
|
id, err := s.db.InsertInsight(db.Insight{
|
|
Type: insight.CannedType,
|
|
DateFrom: req.DateFrom,
|
|
DateTo: req.DateTo,
|
|
Project: project,
|
|
Agent: result.Agent,
|
|
Model: modelPtr,
|
|
Prompt: promptPtr,
|
|
Content: insight.RenderCannedMarkdown(envelope, prov),
|
|
Kind: string(kind),
|
|
SchemaVersion: insight.CannedSchemaVersion,
|
|
TemplateID: prov.TemplateID,
|
|
TemplateVersion: prov.TemplateVersion,
|
|
AggregateHash: aggregateHash,
|
|
CacheKey: cacheKey,
|
|
CacheStatus: "fresh",
|
|
ProvenanceJSON: string(provJSON),
|
|
StructuredJSON: string(structuredJSON),
|
|
})
|
|
if err != nil {
|
|
log.Printf("canned insight insert error: %v", err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to save insight",
|
|
})
|
|
return
|
|
}
|
|
|
|
saved, err := s.db.GetInsight(ctx, id)
|
|
if err != nil || saved == nil {
|
|
log.Printf("canned insight get error: id=%d err=%v",
|
|
id, err)
|
|
sendJSON("error", map[string]string{
|
|
"message": "failed to retrieve saved insight",
|
|
})
|
|
return
|
|
}
|
|
sendJSON("done", saved)
|
|
}
|
|
|
|
func markInsightCacheHit(s *db.Insight) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.CacheStatus = "hit"
|
|
if strings.TrimSpace(s.ProvenanceJSON) == "" {
|
|
return
|
|
}
|
|
var prov map[string]any
|
|
if err := json.Unmarshal([]byte(s.ProvenanceJSON), &prov); err != nil {
|
|
return
|
|
}
|
|
if prov == nil {
|
|
return
|
|
}
|
|
prov["cache_status"] = "hit"
|
|
data, err := json.Marshal(prov)
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.ProvenanceJSON = string(data)
|
|
}
|
|
|
|
func (s *Server) buildCannedPayload(
|
|
ctx context.Context,
|
|
kind insight.CannedKind,
|
|
req generateInsightRequest,
|
|
) (insight.CannedAggregatePayload, string, string, error) {
|
|
filters := insight.CannedSessionFilters{
|
|
Timezone: "UTC",
|
|
AutomatedScope: req.AutomatedScope,
|
|
}
|
|
if req.Filters != nil {
|
|
filters = *req.Filters
|
|
}
|
|
analyticsFilter := db.AnalyticsFilter{
|
|
From: req.DateFrom,
|
|
To: req.DateTo,
|
|
Project: req.Project,
|
|
Machine: filters.Machine,
|
|
Agent: filters.Agent,
|
|
Timezone: filters.Timezone,
|
|
MinUserMessages: filters.MinUserMessages,
|
|
ExcludeOneShot: !filters.IncludeOneShot,
|
|
AutomatedScope: filters.AutomatedScope,
|
|
ActiveSince: filters.ActiveSince,
|
|
Termination: filters.Termination,
|
|
}
|
|
signals, err := s.db.GetAnalyticsSignals(ctx, analyticsFilter)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
|
|
usageFilter := db.UsageFilter{
|
|
From: req.DateFrom,
|
|
To: req.DateTo,
|
|
Project: req.Project,
|
|
Machine: filters.Machine,
|
|
Agent: filters.Agent,
|
|
Timezone: filters.Timezone,
|
|
MinUserMessages: filters.MinUserMessages,
|
|
ExcludeOneShot: !filters.IncludeOneShot,
|
|
AutomatedScope: filters.AutomatedScope,
|
|
ActiveSince: filters.ActiveSince,
|
|
Termination: filters.Termination,
|
|
Breakdowns: false,
|
|
}
|
|
usageResult, err := s.db.GetDailyUsage(ctx, usageFilter)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
topSessions, err := s.db.GetTopSessionsByCost(ctx, usageFilter, 5)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
usageSummary := &insight.CannedUsageSummary{
|
|
InputTokens: usageResult.Totals.InputTokens,
|
|
OutputTokens: usageResult.Totals.OutputTokens,
|
|
CacheCreationTokens: usageResult.Totals.CacheCreationTokens,
|
|
CacheReadTokens: usageResult.Totals.CacheReadTokens,
|
|
TotalCost: usageResult.Totals.TotalCost,
|
|
CacheSavings: usageResult.Totals.CacheSavings,
|
|
ModelBreakdowns: foldCannedModelBreakdowns(usageResult.Daily),
|
|
TopSessionsByCost: topSessions,
|
|
}
|
|
coachSessions, err := s.listCannedCoachSessions(ctx, req)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
coachSummary := insight.BuildCannedCoachSummary(coachSessions)
|
|
|
|
payload := insight.CannedAggregatePayload{
|
|
Kind: kind,
|
|
DateFrom: req.DateFrom,
|
|
DateTo: req.DateTo,
|
|
Project: req.Project,
|
|
AutomatedScope: filters.AutomatedScope,
|
|
Filters: filters,
|
|
Focus: req.Prompt,
|
|
Signals: signals,
|
|
Usage: usageSummary,
|
|
Coach: coachSummary,
|
|
}
|
|
payload.EvidenceRefs = insight.CannedEvidenceRefs(
|
|
signals, usageSummary, coachSummary,
|
|
)
|
|
|
|
aggregateHash, err := insight.CannedAggregateHash(payload)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
cacheKey, err := insight.CannedCacheKey(
|
|
kind, req.DateFrom, req.DateTo, req.Project,
|
|
req.Agent, req.Prompt, aggregateHash,
|
|
filters.AutomatedScope,
|
|
filters,
|
|
)
|
|
if err != nil {
|
|
return insight.CannedAggregatePayload{}, "", "", err
|
|
}
|
|
return payload, aggregateHash, cacheKey, nil
|
|
}
|
|
|
|
func foldCannedModelBreakdowns(
|
|
daily []db.DailyUsageEntry,
|
|
) []insight.CannedModelBreakdown {
|
|
type modelAccum struct {
|
|
inputTok int
|
|
outputTok int
|
|
cacheCr int
|
|
cacheRd int
|
|
cost float64
|
|
}
|
|
byModel := make(map[string]*modelAccum)
|
|
for _, day := range daily {
|
|
for _, model := range day.ModelBreakdowns {
|
|
acc, ok := byModel[model.ModelName]
|
|
if !ok {
|
|
acc = &modelAccum{}
|
|
byModel[model.ModelName] = acc
|
|
}
|
|
acc.inputTok += model.InputTokens
|
|
acc.outputTok += model.OutputTokens
|
|
acc.cacheCr += model.CacheCreationTokens
|
|
acc.cacheRd += model.CacheReadTokens
|
|
acc.cost += model.Cost
|
|
}
|
|
}
|
|
out := make([]insight.CannedModelBreakdown, 0, len(byModel))
|
|
for model, acc := range byModel {
|
|
out = append(out, insight.CannedModelBreakdown{
|
|
ModelName: model,
|
|
InputTokens: acc.inputTok,
|
|
OutputTokens: acc.outputTok,
|
|
CacheCreationTokens: acc.cacheCr,
|
|
CacheReadTokens: acc.cacheRd,
|
|
Cost: acc.cost,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Cost != out[j].Cost {
|
|
return out[i].Cost > out[j].Cost
|
|
}
|
|
return out[i].ModelName < out[j].ModelName
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (s *Server) listCannedCoachSessions(
|
|
ctx context.Context,
|
|
req generateInsightRequest,
|
|
) ([]db.Session, error) {
|
|
filters := insight.CannedSessionFilters{
|
|
Timezone: "UTC",
|
|
AutomatedScope: req.AutomatedScope,
|
|
}
|
|
if req.Filters != nil {
|
|
filters = *req.Filters
|
|
}
|
|
loc := cannedCoachLocation(filters.Timezone)
|
|
dateFrom, dateTo := cannedCoachUTCDateBounds(
|
|
req.DateFrom, req.DateTo, loc,
|
|
)
|
|
filter := db.SessionFilter{
|
|
DateFrom: dateFrom,
|
|
DateTo: dateTo,
|
|
Project: req.Project,
|
|
Machine: filters.Machine,
|
|
Agent: filters.Agent,
|
|
ActiveSince: filters.ActiveSince,
|
|
MinUserMessages: filters.MinUserMessages,
|
|
ExcludeOneShot: !filters.IncludeOneShot,
|
|
AutomatedScope: filters.AutomatedScope,
|
|
Termination: filters.Termination,
|
|
Limit: db.MaxSessionLimit,
|
|
}
|
|
var out []db.Session
|
|
for {
|
|
page, err := s.db.ListSessions(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, session := range page.Sessions {
|
|
if cannedCoachSessionInDateRange(
|
|
session, req.DateFrom, req.DateTo, loc,
|
|
) {
|
|
out = append(out, session)
|
|
}
|
|
}
|
|
if page.NextCursor == "" {
|
|
return out, nil
|
|
}
|
|
filter.Cursor = page.NextCursor
|
|
}
|
|
}
|
|
|
|
func cannedCoachLocation(name string) *time.Location {
|
|
loc, err := time.LoadLocation(name)
|
|
if err != nil {
|
|
return time.UTC
|
|
}
|
|
return loc
|
|
}
|
|
|
|
func cannedCoachUTCDateBounds(
|
|
from, to string,
|
|
loc *time.Location,
|
|
) (string, string) {
|
|
start, err := time.ParseInLocation("2006-01-02", from, loc)
|
|
if err != nil {
|
|
return from, to
|
|
}
|
|
end, err := time.ParseInLocation("2006-01-02", to, loc)
|
|
if err != nil {
|
|
return from, to
|
|
}
|
|
end = end.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
|
return start.UTC().Format("2006-01-02"),
|
|
end.UTC().Format("2006-01-02")
|
|
}
|
|
|
|
func cannedCoachSessionInDateRange(
|
|
session db.Session,
|
|
from, to string,
|
|
loc *time.Location,
|
|
) bool {
|
|
date := cannedCoachSessionLocalDate(session, loc)
|
|
if date == "" {
|
|
return false
|
|
}
|
|
if from != "" && date < from {
|
|
return false
|
|
}
|
|
if to != "" && date > to {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cannedCoachSessionLocalDate(
|
|
session db.Session,
|
|
loc *time.Location,
|
|
) string {
|
|
ts := session.CreatedAt
|
|
if session.StartedAt != nil && *session.StartedAt != "" {
|
|
ts = *session.StartedAt
|
|
}
|
|
t, ok := cannedCoachLocalTime(ts, loc)
|
|
if !ok {
|
|
if len(ts) >= 10 {
|
|
return ts[:10]
|
|
}
|
|
return ""
|
|
}
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
func cannedCoachLocalTime(
|
|
ts string,
|
|
loc *time.Location,
|
|
) (time.Time, bool) {
|
|
t, err := time.Parse(time.RFC3339Nano, ts)
|
|
if err != nil {
|
|
t, err = time.Parse("2006-01-02T15:04:05Z", ts)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
return t.In(loc), true
|
|
}
|