package web import ( "fmt" "strconv" "time" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/uploads" ) // UploadsPage is the upload form plus the caller's own uploads (ADR-033). templ UploadsPage(v uploadsView) { @Layout("Tapir — Upload a recording") {

Upload a recording

Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and { uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you; the audio is deleted once it has been transcribed.

if v.Error != "" { }

if len(v.Rows) > 0 {

Your recordings

}
} } // UploadPage is one upload's page: its title and the polling status region. templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) { @Layout("Tapir — " + u.Title) {

{ u.Title }

@uploadStatus(u, chatEnabled, now)

All recordings

} } // uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the // page re-asks; done and failed render without it, so nothing polls forever. templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) { switch u.Status { case domain.UploadDone:
@videoNotice(u.HadVideo)

Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) } of audio by { u.Endpoint }.

@reconciliationNote(u.Reconciliation) if len(u.Progress) > 0 {
What was done @progressSteps(u.Progress, now, false)
} if chatEnabled {

Ask about this recording

}
Transcript
{ u.Content }
case domain.UploadFailed: default:
@videoNotice(u.HadVideo) if u.Status == domain.UploadTranscribing { {{ health, silent := uploadHealth(u, now) }} if len(u.Progress) > 0 { @progressSteps(u.Progress, now, health != healthLost) @healthNote(health, silent) } else {

Transcribing… a long recording takes a few minutes.

} } else {

Queued — waiting for its turn to be transcribed.

}
} } // UploadChatPage is the no-JS full page for chatting with an uploaded transcript: // the same chat panel as a video's (ADR-027), scoped to this recording. templ UploadChatPage(u domain.UploadedTranscript, v chatView) { @Layout("Tapir — Ask about " + u.Title) {

{ u.Title }

Ask about this recording

Answers come only from this recording's transcript, which is private to you.

@chatPanel(v)

Back to the transcript

} } // videoNotice tells the user a video track was present and what became of it. A // Teams meeting with the camera off is still a video file; it is accepted, only the // audio is transcribed, and the picture is thrown away — so say so, in every state, // instead of leaving the user to wonder (ADR-033). templ videoNotice(hadVideo bool) { if hadVideo {

This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.

} } // reconciliationNote says what was done when two engines transcribed the audio // (ADR-034), and never claims a second opinion that was not there: nil means a single // engine and renders nothing. Spots the model changed or was unsure about are listed // with a minute mark and both readings so the user can check them against the audio. templ reconciliationNote(r *domain.Reconciliation) { if r != nil { switch r.Status { case domain.ReconcileDone:

Reconciled from two transcripts ({ r.Base } and { r.Alt }): { strconv.Itoa(r.Disputes) } differences, { strconv.Itoa(r.AutoResolved) } settled automatically, { strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }), { strconv.Itoa(r.Unsure) } uncertain.

if len(r.Contested) > 0 {
Spots to check against the audio if moreSpots(r) > 0 {

and { strconv.Itoa(moreSpots(r)) } more not listed

}
}
case domain.ReconcileSingle:

Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.

case domain.ReconcileSkipped:

Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.

} } } // progressSteps is the job's phases as a log that fills in as it runs: finished ones // collapse to a line with how long they took, the running one has a spinner and its // elapsed time, and what has not started is dimmed. live is false when the spinner would // be a lie (the service is lost, or the job is over), so the running phase shows a // warning mark instead. Only a counted phase gets a bar (ADR-035). templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
    for _, s := range steps {
  1. if s.State == domain.StepRunning && live { } else if s.State == domain.StepRunning { } else { } { s.Label } if t := stepTime(s, now); t != "" { { t } } if s.State == domain.StepRunning && s.Total > 0 { { strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) } }
  2. }
} // healthNote says so when the job is not simply running: the service stopped reporting, // or a counted phase has gone quiet. Running says nothing: the spinner does. templ healthNote(h jobHealth, silent time.Duration) { switch h { case healthLost: case healthQuiet:

Nothing new for { minutesAgo(silent) }: the model may be slow. The service is still running, and the job gives up on its own if it takes too long.

} }