a76ec74338
C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar, Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry functions http_request, kpi_card, sparkline, agent_runs_timeline, dod_evidence_panel. Backend Go on :8403 (independent operations.db from kanban_web).
604 lines
16 KiB
Go
604 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"fn-registry/functions/core"
|
|
"fn-registry/functions/datascience"
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
type DurationStats = datascience.DurationStats
|
|
|
|
type Metrics struct {
|
|
Range DateRange `json:"range"`
|
|
Totals Totals `json:"totals"`
|
|
ByColumn []ColumnCount `json:"by_column"`
|
|
ThroughputDaily []DailyCount `json:"throughput_daily"`
|
|
CreatedDaily []DailyCount `json:"created_daily"`
|
|
LeadTime DurationStats `json:"lead_time"`
|
|
CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"`
|
|
TopAssignees []AssigneeStat `json:"top_assignees"`
|
|
TopRequesters []RequesterStat `json:"top_requesters"`
|
|
MovementsByUser []MovementStat `json:"movements_by_user"`
|
|
LockTotalMs int64 `json:"lock_total_ms"`
|
|
LockActiveCount int `json:"lock_active_count"`
|
|
CumulativeFlow []CumulativePoint `json:"cumulative_flow"`
|
|
}
|
|
|
|
type CumulativePoint struct {
|
|
Date string `json:"date"`
|
|
Total int `json:"total"`
|
|
Done int `json:"done"`
|
|
}
|
|
|
|
type DateRange struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
type Totals struct {
|
|
Cards int `json:"cards"`
|
|
CardsCompleted int `json:"cards_completed_in_range"`
|
|
CardsCreated int `json:"cards_created_in_range"`
|
|
CardsActive int `json:"cards_active"`
|
|
CardsDone int `json:"cards_done"`
|
|
Columns int `json:"columns"`
|
|
Users int `json:"users"`
|
|
ActiveLocks int `json:"active_locks"`
|
|
}
|
|
|
|
type ColumnCount struct {
|
|
ColumnID string `json:"column_id"`
|
|
Name string `json:"name"`
|
|
IsDone bool `json:"is_done"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type DailyCount struct {
|
|
Date string `json:"date"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type ColumnDuration struct {
|
|
ColumnID string `json:"column_id"`
|
|
Name string `json:"name"`
|
|
IsDone bool `json:"is_done"`
|
|
Stats DurationStats `json:"stats"`
|
|
}
|
|
|
|
type AssigneeStat struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Active int `json:"active"`
|
|
Completed int `json:"completed_in_range"`
|
|
}
|
|
|
|
type RequesterStat struct {
|
|
Requester string `json:"requester"`
|
|
Total int `json:"total"`
|
|
Active int `json:"active"`
|
|
Completed int `json:"completed_in_range"`
|
|
}
|
|
|
|
type MovementStat struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Moves int `json:"moves"`
|
|
}
|
|
|
|
func computeStats(durations []int64) DurationStats {
|
|
return datascience.DurationStatsFrom(durations)
|
|
}
|
|
|
|
func parseDateOrDefault(s string, dflt time.Time) time.Time {
|
|
return core.ParseDateOrDefault(s, dflt, false)
|
|
}
|
|
|
|
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
|
|
return core.ParseDateOrDefault(s, dflt, true)
|
|
}
|
|
|
|
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
|
|
func handleMetrics(db *DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
now := time.Now().UTC()
|
|
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
|
|
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
|
|
assignee := r.URL.Query().Get("assignee_id")
|
|
requester := r.URL.Query().Get("requester")
|
|
tagsRaw := r.URL.Query().Get("tags")
|
|
var tags []string
|
|
if tagsRaw != "" {
|
|
for _, t := range strings.Split(tagsRaw, ",") {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
m, err := computeMetrics(db, from, to, assignee, requester, tags)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, m)
|
|
}
|
|
}
|
|
|
|
func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) {
|
|
fromStr := from.Format(time.RFC3339Nano)
|
|
toStr := to.Format(time.RFC3339Nano)
|
|
|
|
m := &Metrics{
|
|
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
|
|
ByColumn: []ColumnCount{},
|
|
ThroughputDaily: []DailyCount{},
|
|
CreatedDaily: []DailyCount{},
|
|
CycleTimeColumn: []ColumnDuration{},
|
|
TopAssignees: []AssigneeStat{},
|
|
TopRequesters: []RequesterStat{},
|
|
MovementsByUser: []MovementStat{},
|
|
CumulativeFlow: []CumulativePoint{},
|
|
}
|
|
|
|
cardWhere := "WHERE deleted_at IS NULL"
|
|
args := []any{}
|
|
if assignee != "" {
|
|
cardWhere += " AND assignee_id=?"
|
|
args = append(args, assignee)
|
|
}
|
|
if requester != "" {
|
|
cardWhere += " AND requester=?"
|
|
args = append(args, requester)
|
|
}
|
|
for _, t := range tags {
|
|
cardWhere += " AND tags LIKE ?"
|
|
args = append(args, `%"`+t+`"%`)
|
|
}
|
|
|
|
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
|
|
return nil, err
|
|
}
|
|
completedArgs := append([]any{fromStr, toStr}, args...)
|
|
if err := db.conn.QueryRow(
|
|
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
|
|
append(args, fromStr, toStr)...,
|
|
).Scan(&m.Totals.CardsCompleted); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.conn.QueryRow(
|
|
`SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`,
|
|
append(args, fromStr, toStr)...,
|
|
).Scan(&m.Totals.CardsCreated); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.conn.QueryRow(
|
|
`SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`,
|
|
args...,
|
|
).Scan(&m.Totals.CardsActive); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.conn.QueryRow(
|
|
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`,
|
|
args...,
|
|
).Scan(&m.Totals.CardsDone); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = completedArgs
|
|
|
|
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil {
|
|
return nil, err
|
|
}
|
|
lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL`
|
|
if assignee != "" {
|
|
lockActiveQ += ` AND c.assignee_id=?`
|
|
}
|
|
if requester != "" {
|
|
lockActiveQ += ` AND c.requester=?`
|
|
}
|
|
if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// By column.
|
|
rows, err := db.conn.Query(
|
|
`SELECT col.id, col.name, col.is_done, COUNT(c.id)
|
|
FROM columns col
|
|
LEFT JOIN cards c ON c.column_id=col.id`+
|
|
condFromCard(assignee, requester, "c", "WHERE")+
|
|
` GROUP BY col.id ORDER BY col.position`,
|
|
colArgs(assignee, requester)...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var cc ColumnCount
|
|
var isDone int
|
|
if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
cc.IsDone = isDone != 0
|
|
m.ByColumn = append(m.ByColumn, cc)
|
|
}
|
|
rows.Close()
|
|
|
|
// Throughput daily (completed_at within range).
|
|
m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Lead time (cards completed in range, completed_at - created_at).
|
|
leadDurs, err := collectDurations(db,
|
|
`SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+
|
|
cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
|
|
append(args, fromStr, toStr)...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.LeadTime = computeStats(leadDurs)
|
|
|
|
// Cycle time per column.
|
|
colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type colInfo struct {
|
|
id, name string
|
|
isDone bool
|
|
}
|
|
var cols []colInfo
|
|
for colRows.Next() {
|
|
var ci colInfo
|
|
var d int
|
|
if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil {
|
|
colRows.Close()
|
|
return nil, err
|
|
}
|
|
ci.isDone = d != 0
|
|
cols = append(cols, ci)
|
|
}
|
|
colRows.Close()
|
|
|
|
now := time.Now().UTC()
|
|
cap := to
|
|
if now.Before(cap) {
|
|
cap = now
|
|
}
|
|
capStr := cap.Format(time.RFC3339Nano)
|
|
for _, ci := range cols {
|
|
histArgs := []any{ci.id, fromStr, toStr}
|
|
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
|
|
FROM card_column_history h JOIN cards c ON c.id=h.card_id
|
|
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
|
|
histArgs = append([]any{capStr}, histArgs...)
|
|
if assignee != "" {
|
|
histQ += ` AND c.assignee_id=?`
|
|
histArgs = append(histArgs, assignee)
|
|
}
|
|
if requester != "" {
|
|
histQ += ` AND c.requester=?`
|
|
histArgs = append(histArgs, requester)
|
|
}
|
|
durs, err := collectDurations(db, histQ, histArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{
|
|
ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone,
|
|
Stats: computeStats(durs),
|
|
})
|
|
}
|
|
|
|
// Top assignees.
|
|
asRows, err := db.conn.Query(
|
|
`SELECT u.id, u.username, u.display_name,
|
|
SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active,
|
|
SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed
|
|
FROM users u
|
|
LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) +
|
|
` GROUP BY u.id ORDER BY completed DESC, active DESC`,
|
|
topAssigneeArgs(fromStr, toStr, requester)...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for asRows.Next() {
|
|
var s AssigneeStat
|
|
if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil {
|
|
asRows.Close()
|
|
return nil, err
|
|
}
|
|
m.TopAssignees = append(m.TopAssignees, s)
|
|
}
|
|
asRows.Close()
|
|
|
|
// Top requesters.
|
|
reqRows, err := db.conn.Query(
|
|
`SELECT requester,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active,
|
|
SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed
|
|
FROM cards
|
|
WHERE deleted_at IS NULL AND requester != ''`+
|
|
condFromCard(assignee, "", "", "AND")+
|
|
` GROUP BY requester ORDER BY total DESC LIMIT 10`,
|
|
topReqArgs(fromStr, toStr, assignee)...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for reqRows.Next() {
|
|
var s RequesterStat
|
|
if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil {
|
|
reqRows.Close()
|
|
return nil, err
|
|
}
|
|
m.TopRequesters = append(m.TopRequesters, s)
|
|
}
|
|
reqRows.Close()
|
|
|
|
// Movements by user.
|
|
mvRows, err := db.conn.Query(
|
|
`SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves
|
|
FROM users u
|
|
LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=?
|
|
GROUP BY u.id ORDER BY moves DESC`,
|
|
fromStr, toStr,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for mvRows.Next() {
|
|
var s MovementStat
|
|
if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil {
|
|
mvRows.Close()
|
|
return nil, err
|
|
}
|
|
m.MovementsByUser = append(m.MovementsByUser, s)
|
|
}
|
|
mvRows.Close()
|
|
|
|
// Lock total in range.
|
|
var lockMs float64
|
|
if err := db.conn.QueryRow(
|
|
`SELECT COALESCE(SUM(
|
|
(julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000
|
|
), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id
|
|
WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"),
|
|
append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)...,
|
|
).Scan(&lockMs); err != nil {
|
|
return nil, err
|
|
}
|
|
m.LockTotalMs = int64(lockMs)
|
|
|
|
// Cumulative flow: walk daily from→to, count cards created<=day and done<=day.
|
|
cfd, err := computeCumulativeFlow(db, from, to, assignee, requester)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.CumulativeFlow = cfd
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) {
|
|
creates := map[string]int{}
|
|
dones := map[string]int{}
|
|
|
|
cardWhere := "WHERE deleted_at IS NULL"
|
|
args := []any{}
|
|
if assignee != "" {
|
|
cardWhere += " AND assignee_id=?"
|
|
args = append(args, assignee)
|
|
}
|
|
if requester != "" {
|
|
cardWhere += " AND requester=?"
|
|
args = append(args, requester)
|
|
}
|
|
|
|
rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var d string
|
|
var n int
|
|
if err := rows.Scan(&d, &n); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
creates[d] = n
|
|
}
|
|
rows.Close()
|
|
|
|
rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var d string
|
|
var n int
|
|
if err := rows.Scan(&d, &n); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
dones[d] = n
|
|
}
|
|
rows.Close()
|
|
|
|
out := []CumulativePoint{}
|
|
totalAcc := 0
|
|
doneAcc := 0
|
|
day := from
|
|
end := to
|
|
if end.Before(day) {
|
|
return out, nil
|
|
}
|
|
for d := day; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
ds := d.Format("2006-01-02")
|
|
// Sum all creates with key <= ds, all dones with key <= ds.
|
|
// Optimize: track keys already accounted; here we just do once per loop using map sums.
|
|
_ = ds
|
|
}
|
|
// Simpler: collect and sort all create/done dates, sweep.
|
|
type ev struct {
|
|
date string
|
|
creates int
|
|
dones int
|
|
}
|
|
all := map[string]*ev{}
|
|
for d, n := range creates {
|
|
all[d] = &ev{date: d, creates: n}
|
|
}
|
|
for d, n := range dones {
|
|
if e, ok := all[d]; ok {
|
|
e.dones = n
|
|
} else {
|
|
all[d] = &ev{date: d, dones: n}
|
|
}
|
|
}
|
|
dates := make([]string, 0, len(all))
|
|
for d := range all {
|
|
dates = append(dates, d)
|
|
}
|
|
sort.Strings(dates)
|
|
|
|
// Accumulate up to `from` first.
|
|
fromS := from.Format("2006-01-02")
|
|
idx := 0
|
|
for idx < len(dates) && dates[idx] < fromS {
|
|
totalAcc += all[dates[idx]].creates
|
|
doneAcc += all[dates[idx]].dones
|
|
idx++
|
|
}
|
|
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
ds := d.Format("2006-01-02")
|
|
for idx < len(dates) && dates[idx] <= ds {
|
|
totalAcc += all[dates[idx]].creates
|
|
doneAcc += all[dates[idx]].dones
|
|
idx++
|
|
}
|
|
out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func condFromCard(assignee, requester, alias, leadKw string) string {
|
|
pref := alias
|
|
if pref != "" {
|
|
pref += "."
|
|
}
|
|
out := ""
|
|
if assignee != "" {
|
|
out += " " + leadKw + " " + pref + "assignee_id=?"
|
|
leadKw = "AND"
|
|
}
|
|
if requester != "" {
|
|
out += " " + leadKw + " " + pref + "requester=?"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func colArgs(assignee, requester string) []any {
|
|
args := []any{}
|
|
if assignee != "" {
|
|
args = append(args, assignee)
|
|
}
|
|
if requester != "" {
|
|
args = append(args, requester)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func cardJoinFilter(requester string) string {
|
|
if requester != "" {
|
|
return " AND c.requester=?"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func topAssigneeArgs(fromStr, toStr, requester string) []any {
|
|
args := []any{fromStr, toStr}
|
|
if requester != "" {
|
|
args = append(args, requester)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func topReqArgs(fromStr, toStr, assignee string) []any {
|
|
args := []any{fromStr, toStr}
|
|
if assignee != "" {
|
|
args = append(args, assignee)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func collectDurations(db *DB, query string, args ...any) ([]int64, error) {
|
|
rows, err := db.conn.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []int64{}
|
|
for rows.Next() {
|
|
var v float64
|
|
if err := rows.Scan(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
if v < 0 {
|
|
v = 0
|
|
}
|
|
out = append(out, int64(v))
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) {
|
|
cardWhere := "deleted_at IS NULL"
|
|
if requireNonNull {
|
|
cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''"
|
|
}
|
|
cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?"
|
|
args := []any{fromStr, toStr}
|
|
if assignee != "" {
|
|
cardWhere += " AND assignee_id=?"
|
|
args = append(args, assignee)
|
|
}
|
|
if requester != "" {
|
|
cardWhere += " AND requester=?"
|
|
args = append(args, requester)
|
|
}
|
|
q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d`
|
|
rows, err := db.conn.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []DailyCount{}
|
|
for rows.Next() {
|
|
var dc DailyCount
|
|
if err := rows.Scan(&dc.Date, &dc.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, dc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|