596 lines
22 KiB
Go
596 lines
22 KiB
Go
package tools
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||
)
|
||
|
||
// Assignments: the first tools that change something, and the two lookups that
|
||
// make changing something possible.
|
||
//
|
||
// §4 has a line that dictates the shape of this file: "a tool description that
|
||
// requires the model to guess an ID it has not been given is a design bug. Add
|
||
// a lookup tool instead." Every read tool built so far returns aggregates —
|
||
// counts, averages, the weakest five — and none of them return a row id,
|
||
// deliberately: an id in an analytics answer is noise. But an assignment names
|
||
// a posting and a person, so the write is unusable until the model has a
|
||
// legitimate way to learn those two things.
|
||
//
|
||
// Hence three tools, in the order an agent actually uses them:
|
||
//
|
||
// open_positions → which roles need people, with their ids
|
||
// available_workers → who is free in that window, with their emails
|
||
// assign_worker → put one on the other, once a person has said yes
|
||
//
|
||
// The alternative — a single write that accepts a worker's name and resolves it
|
||
// itself — reads as friendlier and is considerably worse. Two people called
|
||
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
|
||
// after approval, with no one watching.
|
||
|
||
/* ── Lookup: open positions ─────────────────────────────────────────────── */
|
||
|
||
type openPositionsInput struct {
|
||
Limit int `json:"limit"`
|
||
}
|
||
|
||
// OpenPositions lists roles that still need people, with the ids to fill them.
|
||
func OpenPositions(db repo.Querier) Tool {
|
||
return Tool{
|
||
Name: "open_positions",
|
||
Description: "List the roles that are currently open, with how many people each " +
|
||
"needs, how many are already assigned, and how many are still to fill. " +
|
||
"Returns an id for each role. Use this before assigning anyone: it is the " +
|
||
"only way to learn a role's id, and an id must never be guessed.",
|
||
InputSchema: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"limit": map[string]any{
|
||
"type": "integer", "minimum": 1, "maximum": 100,
|
||
"description": "How many roles to list, most urgent first. Defaults to 20.",
|
||
},
|
||
},
|
||
"additionalProperties": false,
|
||
},
|
||
Effect: EffectRead,
|
||
MaxResultBytes: DefaultMaxResultBytes,
|
||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||
q, denied := authorizeAs(tc, "job-postings", "p")
|
||
if denied != nil {
|
||
return *denied
|
||
}
|
||
var in openPositionsInput
|
||
if len(inputs) > 0 {
|
||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||
}
|
||
}
|
||
limit := in.Limit
|
||
if limit <= 0 {
|
||
limit = 20
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
|
||
// Only roles that can actually be staffed. A draft has not been
|
||
// agreed, a paused role has been stopped on purpose, and a closed
|
||
// one is history — offering any of them as assignable would invite
|
||
// the agent to staff a role nobody is hiring for.
|
||
q.raw("p.status = 'active'")
|
||
|
||
args := append(append([]any{}, q.args...), limit)
|
||
rows, err := db.Query(ctx, `
|
||
SELECT p.id::text, p.title, p.role_category, p.location,
|
||
p.headcount, p.priority::text, p.start_date,
|
||
(SELECT count(*) FROM assignments a
|
||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
|
||
AND a.status = 'active')
|
||
FROM job_postings p
|
||
WHERE `+q.clause()+`
|
||
ORDER BY p.priority DESC, p.created_date ASC
|
||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||
if err != nil {
|
||
return Failf(CodeFailed, "the open roles could not be read")
|
||
}
|
||
defer rows.Close()
|
||
|
||
positions := []map[string]any{}
|
||
for rows.Next() {
|
||
var (
|
||
id, title, category, location, priority string
|
||
headcount int
|
||
startDate *time.Time
|
||
assigned int
|
||
)
|
||
if err := rows.Scan(&id, &title, &category, &location,
|
||
&headcount, &priority, &startDate, &assigned); err != nil {
|
||
return Failf(CodeFailed, "the open roles could not be read")
|
||
}
|
||
stillToFill := headcount - assigned
|
||
if stillToFill < 0 {
|
||
stillToFill = 0
|
||
}
|
||
p := map[string]any{
|
||
"id": id, "title": title, "priority": priority,
|
||
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
|
||
}
|
||
if category != "" {
|
||
p["roleCategory"] = category
|
||
}
|
||
if location != "" {
|
||
p["location"] = location
|
||
}
|
||
if startDate != nil {
|
||
p["startDate"] = startDate.Format("2006-01-02")
|
||
}
|
||
positions = append(positions, p)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return Failf(CodeFailed, "the open roles could not be read")
|
||
}
|
||
|
||
data := map[string]any{"positions": positions, "count": len(positions)}
|
||
if len(positions) == 0 {
|
||
data["note"] = "No roles are open. This is a real answer, not a failure to look."
|
||
}
|
||
return OK(data)
|
||
},
|
||
}
|
||
}
|
||
|
||
/* ── Lookup: available workers ──────────────────────────────────────────── */
|
||
|
||
type availableWorkersInput struct {
|
||
StartsAt string `json:"starts_at"`
|
||
EndsAt string `json:"ends_at"`
|
||
Limit int `json:"limit"`
|
||
}
|
||
|
||
// AvailableWorkers lists workers with no clashing assignment in a window.
|
||
//
|
||
// "Available" here means one specific, checkable thing: no active assignment
|
||
// overlapping the window. It does not mean willing, qualified, or within their
|
||
// contracted hours. The description says so, because a model given a tool
|
||
// called `available_workers` will otherwise report its output as availability
|
||
// in the ordinary sense of the word, and a manager will read it that way.
|
||
func AvailableWorkers(db repo.Querier) Tool {
|
||
return Tool{
|
||
Name: "available_workers",
|
||
Description: "List workers who have no clashing assignment in a given window, " +
|
||
"best-scoring first, with the email needed to assign them. Availability here " +
|
||
"means only that nothing else is booked over that window — it does not mean " +
|
||
"the person has agreed, is qualified for the role, or is within their hours. " +
|
||
"Say so when reporting it.",
|
||
InputSchema: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"starts_at": map[string]any{
|
||
"type": "string",
|
||
"description": "When the work starts, as an RFC 3339 timestamp " +
|
||
"(for example 2026-09-12T18:00:00Z). Required.",
|
||
},
|
||
"ends_at": map[string]any{
|
||
"type": "string",
|
||
"description": "When the work ends, as an RFC 3339 timestamp. " +
|
||
"Omit for open-ended work.",
|
||
},
|
||
"limit": map[string]any{
|
||
"type": "integer", "minimum": 1, "maximum": 100,
|
||
"description": "How many workers to list. Defaults to 10.",
|
||
},
|
||
},
|
||
"required": []string{"starts_at"},
|
||
"additionalProperties": false,
|
||
},
|
||
Effect: EffectRead,
|
||
MaxResultBytes: DefaultMaxResultBytes,
|
||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||
q, denied := authorizeAs(tc, "worker-profiles", "w")
|
||
if denied != nil {
|
||
return *denied
|
||
}
|
||
var in availableWorkersInput
|
||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||
}
|
||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||
if bad != nil {
|
||
return *bad
|
||
}
|
||
limit := in.Limit
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
|
||
// The clash test is a NOT EXISTS against the same tenant, so it is
|
||
// a pre-filter like every other predicate here rather than a list
|
||
// fetched and then thinned in Go.
|
||
args := append(append([]any{}, q.args...), starts, ends, limit)
|
||
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
|
||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
|
||
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
|
||
w.current_position
|
||
FROM worker_profiles w
|
||
WHERE %s
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM assignments a
|
||
WHERE a.org_id = w.org_id
|
||
AND a.worker_email = w.email
|
||
AND a.status = 'active'
|
||
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
|
||
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
|
||
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
|
||
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
|
||
if err != nil {
|
||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||
}
|
||
defer rows.Close()
|
||
|
||
workers := []map[string]any{}
|
||
for rows.Next() {
|
||
// Pointers, so an unrated worker carries no rating rather than a
|
||
// 0 the model would read as the worst possible score. This list
|
||
// feeds assign_worker, so the distinction picks who gets offered.
|
||
var (
|
||
name, email, position string
|
||
krow, reliability, experience *int
|
||
)
|
||
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
|
||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||
}
|
||
w := map[string]any{"name": name, "email": email}
|
||
if krow != nil {
|
||
w["krowScore"] = *krow
|
||
}
|
||
if reliability != nil {
|
||
w["reliabilityScore"] = *reliability
|
||
}
|
||
if experience != nil {
|
||
w["experienceYears"] = *experience
|
||
}
|
||
if position != "" {
|
||
w["currentPosition"] = position
|
||
}
|
||
workers = append(workers, w)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||
}
|
||
|
||
data := map[string]any{
|
||
"window": windowLabel(starts, ends),
|
||
"workers": workers,
|
||
"count": len(workers),
|
||
"meaning": "No clashing assignment in this window. Not a statement that " +
|
||
"they have agreed or are qualified.",
|
||
}
|
||
if len(workers) == 0 {
|
||
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
|
||
}
|
||
return OK(data)
|
||
},
|
||
}
|
||
}
|
||
|
||
/* ── Write: assign a worker ─────────────────────────────────────────────── */
|
||
|
||
type assignWorkerInput struct {
|
||
JobPostingID string `json:"job_posting_id"`
|
||
WorkerEmail string `json:"worker_email"`
|
||
StartsAt string `json:"starts_at"`
|
||
EndsAt string `json:"ends_at"`
|
||
}
|
||
|
||
// AssignWorker puts a worker on a role. The first tool in this service that
|
||
// changes anything.
|
||
//
|
||
// The pattern every future write should copy is the split between Confirm and
|
||
// Handler, and specifically what is duplicated across them. Both authorize.
|
||
// Both resolve the posting and the worker. Both check for a clash. That looks
|
||
// like repetition and is not: the renderer runs to describe, and the handler
|
||
// runs after a person has read that description and agreed — with an unbounded
|
||
// gap in between, during which somebody else may have taken the same shift.
|
||
//
|
||
// So the renderer's clash check produces a WARNING, and the handler's produces
|
||
// a REFUSAL. A description is about the moment it was written; a write is about
|
||
// the moment it happens.
|
||
func AssignWorker(db repo.Querier) Tool {
|
||
return Tool{
|
||
Name: "assign_worker",
|
||
Description: "Assign a worker to an open role for a given period. This creates a " +
|
||
"real assignment: the person is scheduled to work. Requires a role id from " +
|
||
"open_positions and a worker email from available_workers — never invent " +
|
||
"either. A person must approve before this takes effect.",
|
||
InputSchema: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"job_posting_id": map[string]any{
|
||
"type": "string",
|
||
"description": "The role's id, exactly as returned by open_positions.",
|
||
},
|
||
"worker_email": map[string]any{
|
||
"type": "string",
|
||
"description": "The worker's email, exactly as returned by available_workers.",
|
||
},
|
||
"starts_at": map[string]any{
|
||
"type": "string",
|
||
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
|
||
},
|
||
"ends_at": map[string]any{
|
||
"type": "string",
|
||
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
|
||
},
|
||
},
|
||
"required": []string{"job_posting_id", "worker_email", "starts_at"},
|
||
"additionalProperties": false,
|
||
},
|
||
Effect: EffectWrite,
|
||
MaxResultBytes: DefaultMaxResultBytes,
|
||
|
||
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
|
||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||
if denied != nil {
|
||
return nil, denied
|
||
}
|
||
|
||
details := []Detail{
|
||
{Label: "Worker", Value: plan.workerName},
|
||
{Label: "Role", Value: plan.postingTitle},
|
||
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
|
||
}
|
||
if plan.location != "" {
|
||
details = append(details, Detail{Label: "Location", Value: plan.location})
|
||
}
|
||
details = append(details, Detail{
|
||
Label: "Role filled",
|
||
Value: fmt.Sprintf("%d of %d, this would make %d",
|
||
plan.assigned, plan.headcount, plan.assigned+1),
|
||
})
|
||
|
||
var warnings []string
|
||
if plan.clashes > 0 {
|
||
warnings = append(warnings, fmt.Sprintf(
|
||
"%s already has %s over this period. Assigning them will double-book.",
|
||
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
|
||
}
|
||
if plan.assigned >= plan.headcount {
|
||
warnings = append(warnings, fmt.Sprintf(
|
||
"%s already has all %s it asked for. This would go over headcount.",
|
||
plan.postingTitle, plural(plan.headcount, "person", "people")))
|
||
}
|
||
if plan.starts.Before(time.Now()) {
|
||
warnings = append(warnings, "This period starts in the past.")
|
||
}
|
||
|
||
return &Confirmation{
|
||
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
|
||
Summary: fmt.Sprintf(
|
||
"%s will be scheduled to work %s as %s. They will appear on the roster "+
|
||
"for this role and count towards its headcount.",
|
||
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
|
||
Details: details,
|
||
Warnings: warnings,
|
||
}, nil
|
||
},
|
||
|
||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||
if denied != nil {
|
||
return *denied
|
||
}
|
||
|
||
// Re-checked here, not merely described above. The approval was
|
||
// given against a picture of the world that is now some minutes
|
||
// old, and a double-booking created between the asking and the
|
||
// answering is one nobody agreed to.
|
||
if plan.clashes > 0 {
|
||
return Failf(CodeFailed,
|
||
"%s was booked over this period since this was approved; nothing was assigned",
|
||
plan.workerName)
|
||
}
|
||
|
||
var id string
|
||
err := db.QueryRow(ctx, `
|
||
INSERT INTO assignments
|
||
(org_id, job_posting_id, worker_profile_id, worker_email,
|
||
worker_name, starts_at, ends_at, status, source, match_score)
|
||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
|
||
RETURNING id::text`,
|
||
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
|
||
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
|
||
).Scan(&id)
|
||
if err != nil {
|
||
return Failf(CodeFailed, "the assignment could not be created")
|
||
}
|
||
|
||
return OK(map[string]any{
|
||
"assignmentId": id,
|
||
"worker": plan.workerName,
|
||
"role": plan.postingTitle,
|
||
"period": windowLabel(plan.starts, plan.ends),
|
||
"status": "active",
|
||
"confirmed": true,
|
||
})
|
||
},
|
||
}
|
||
}
|
||
|
||
// assignmentPlan is everything both Confirm and Handler need, resolved once.
|
||
type assignmentPlan struct {
|
||
postingID string
|
||
postingTitle string
|
||
location string
|
||
headcount int
|
||
assigned int
|
||
workerProfileID *string
|
||
workerEmail string
|
||
workerName string
|
||
matchScore *int
|
||
starts time.Time
|
||
ends *time.Time
|
||
clashes int
|
||
}
|
||
|
||
// planAssignment authorizes, validates and resolves an assign_worker call.
|
||
//
|
||
// Shared by the renderer and the handler so that the thing described and the
|
||
// thing done are resolved by the same code. Two separate resolutions would
|
||
// drift, and the drift would land exactly where nobody looks: between what a
|
||
// person approved and what then happened.
|
||
//
|
||
// Every refusal is the same opaque Denied(). A posting id that belongs to
|
||
// another tenant, one that does not exist, and one this caller may not see are
|
||
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
|
||
// a given uuid is real.
|
||
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
|
||
// Create, not List. `assignments` lists to everyone and creates for
|
||
// operators only, so a talent caller is refused here even though they may
|
||
// read their own roster perfectly well.
|
||
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
|
||
return nil, denied
|
||
}
|
||
|
||
var in assignWorkerInput
|
||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||
return nil, &bad
|
||
}
|
||
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
|
||
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
|
||
return nil, &bad
|
||
}
|
||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||
if bad != nil {
|
||
return nil, bad
|
||
}
|
||
|
||
plan := &assignmentPlan{starts: starts, ends: ends}
|
||
|
||
// The posting, behind the caller's own read predicate. Referencing a row
|
||
// the caller could not have read would confirm it exists.
|
||
pq, denied := authorizeAs(tc, "job-postings", "p")
|
||
if denied != nil {
|
||
return nil, denied
|
||
}
|
||
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
|
||
pq.raw("p.status = 'active'")
|
||
err := db.QueryRow(ctx, `
|
||
SELECT p.id::text, p.title, p.location, p.headcount,
|
||
(SELECT count(*) FROM assignments a
|
||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
|
||
FROM job_postings p
|
||
WHERE `+pq.clause(), pq.args...,
|
||
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
|
||
if err != nil {
|
||
denied := Denied()
|
||
return nil, &denied
|
||
}
|
||
|
||
// The worker, likewise.
|
||
wq, denied := authorizeAs(tc, "worker-profiles", "w")
|
||
if denied != nil {
|
||
return nil, denied
|
||
}
|
||
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
|
||
var profileID string
|
||
if err := db.QueryRow(ctx, `
|
||
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
|
||
FROM worker_profiles w
|
||
WHERE `+wq.clause()+`
|
||
LIMIT 1`, wq.args...,
|
||
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
|
||
denied := Denied()
|
||
return nil, &denied
|
||
}
|
||
plan.workerProfileID = &profileID
|
||
if strings.TrimSpace(plan.workerName) == "" {
|
||
plan.workerName = plan.workerEmail
|
||
}
|
||
|
||
// Clashes: active assignments overlapping the window. Counted in SQL —
|
||
// fetching the person's roster and comparing in Go would be the same
|
||
// post-filter I2 forbids, and would read rows this call has no reason to.
|
||
if err := db.QueryRow(ctx, `
|
||
SELECT count(*) FROM assignments
|
||
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
|
||
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
|
||
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
|
||
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
|
||
).Scan(&plan.clashes); err != nil {
|
||
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
|
||
return nil, &failed
|
||
}
|
||
|
||
return plan, nil
|
||
}
|
||
|
||
/* ── Shared helpers ─────────────────────────────────────────────────────── */
|
||
|
||
// decodeWindow parses a caller-supplied period.
|
||
//
|
||
// RFC 3339 only, and validated here rather than at the database. A timestamp
|
||
// that reaches SQL as an uninterpretable string is a constraint violation
|
||
// wearing the costume of a tool failure, and the model cannot correct what it
|
||
// cannot read.
|
||
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
|
||
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
|
||
if err != nil {
|
||
bad := Failf(CodeInvalidInput,
|
||
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
|
||
return time.Time{}, nil, &bad
|
||
}
|
||
if strings.TrimSpace(endsAt) == "" {
|
||
return starts, nil, nil
|
||
}
|
||
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
|
||
if err != nil {
|
||
bad := Failf(CodeInvalidInput,
|
||
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
|
||
return time.Time{}, nil, &bad
|
||
}
|
||
// The table has a CHECK for this. Caught here so the model gets a sentence
|
||
// it can act on rather than a constraint name it cannot.
|
||
if !ends.After(starts) {
|
||
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
|
||
return time.Time{}, nil, &bad
|
||
}
|
||
return starts, &ends, nil
|
||
}
|
||
|
||
// windowLabel renders a period the way a person reads one.
|
||
func windowLabel(starts time.Time, ends *time.Time) string {
|
||
if ends == nil {
|
||
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
|
||
}
|
||
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
|
||
return fmt.Sprintf("%s–%s",
|
||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
|
||
}
|
||
return fmt.Sprintf("%s – %s",
|
||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
|
||
}
|
||
|
||
func nullableTime(t *time.Time) any {
|
||
if t == nil {
|
||
return nil
|
||
}
|
||
return *t
|
||
}
|
||
|
||
func plural(n int, one, many string) string {
|
||
if n == 1 {
|
||
return fmt.Sprintf("1 %s", one)
|
||
}
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|