A finished case is handled in one gesture: a text button below the
document copies the document text to the clipboard and then closes the
case, landing back on /cases. Replaces the icon-only trash-can close
that sat in the H1 title row.
Behaviour:
- Open case with a document: "Kopieren + Schließen" below the document.
The submit handler awaits the clipboard write inside the user gesture,
then submits the close form; a FAILED copy aborts the close and reveals
an inline error, so a finished case never disappears from the default
list while the clipboard is empty.
- Open case without a document: a bare "Schließen" button (nothing to
copy) keeps the close affordance the removed trash-can used to provide.
- Closed case: neither button renders; the reopen affordance is
untouched.
Frontend-only. No Rust route/handler change: POST /cases/{id}/close
already strips the case-page Referer to /cases, so "back to list" needs
no new server work. The two-path clipboard routine (async Clipboard API
+ textarea/execCommand fallback) is extracted into a shared copyDocText()
used by both the standalone icon copy button (kept, copy-without-close)
and the new combined control — one source, no drift.
Tests (RED-first, server/tests/case_page_test.rs): open+document renders
the copy-close form with the close action and CSRF token; closed case
hides it; the H1 trash-can is gone on an open case; the documentless
open case keeps a bare Schließen. Full server suite green, clippy clean,
fmt no-op. The JS submit-handler ordering (copy-before-navigate,
abort-on-fail) has no Rust render harness — same inherent layer gap as
the pre-existing #copy-btn — and is verified manually.
Covers the case-page half of #14; the per-row case-list copy+close is
deferred.
refs #14
Follow-up to 6d1ca71: that commit made the recordings sub-page render for a
closed case by threading ?show_closed=1, but the page's own links and the
audio player still 404'd — playback was dead and the back link led nowhere.
Root cause: the `.closed` marker was implemented as an ACCESS gate (every
case-scoped read 404s a closed case unless the opt-in flag is threaded
through), while it is documented as a DISCOVERY gate ("hidden from the
default listing"). Threading the flag through every link/redirect is
whack-a-mole; the next new link forgets it.
Resolved in favour of discovery-gate semantics: the `.closed` marker only
filters the default case LIST (scan_user_cases). Direct/deep-link access to
a known, owned case and all its sub-resources is closed-agnostic. IDOR is
unchanged — it comes from the per-user root + existence check, orthogonal
to closed-ness.
Scope 1 — closed cases are fully viewable:
- handle_audio: drop the is_closed -> 404 gate (web.rs). Audio is a
capability URL behind the same per-user path + slug/UUID/filename
validation as an open case.
- handle_case_page / handle_case_recordings: always resolve via
locate_closed_case_or_404; show_closed is no longer an access gate, so
back links (recordings -> case, case -> list) reach live pages without
threading the flag.
- the "back to list" links carry ?show_closed=1 when the case is closed
(computed server-side from the marker) so the user returns to the
closed-inclusive list, not the default list that hides the case.
Scope 2 — closed means read-only:
- case_page.html withholds the analyze / reset / re-analyze / retry forms
on closed cases; only the reopen affordance remains.
- case_recordings.html withholds the per-recording delete form on closed
cases (CaseRecordingsTemplate gains is_closed).
- the mutating handlers (analyze/reset/delete/oneliner) keep rejecting
closed cases via locate_case_or_404 as defense-in-depth behind the
now-hidden buttons.
Tests (RED-first):
- web_test: audio serves a closed case's file (200, not 404).
- case_page_test: closed case page + recordings render without
?show_closed=1; closed case hides analyze/reset; open case still shows
delete; closed case hides delete.
- analyze_test: closed_case_returns_404_on_detail rewritten to
closed_case_detail_renders_read_only — the old test pinned the
now-superseded access-gate contract.
cargo test (440 passed), clippy, and fmt all clean.
closes#17
Following "Aufnahmen anzeigen" on a closed case returned HTTP 404
{"error":"Case not found"} — an apparently empty page. handle_case_recordings
unconditionally resolved the case via locate_case_or_404, which rejects any
case carrying the .closed marker, so the recordings (which stay on disk after
a close — only a marker is written) became unreachable.
Fix mirrors handle_case_page: the handler now takes Query<CaseListQuery> and
branches to locate_closed_case_or_404 when show_closed=1 is requested, falling
back to the IDOR-guarded locate_case_or_404 otherwise. The in-app link in
case_page.html now carries ?show_closed=1 for closed cases (mirroring
my_cases.html), so navigation from a closed case's detail page no longer drops
the flag and 404s.
Reuses the existing CaseListQuery/include_closed() extractor; no parallel type.
Minimal fix, no surrounding refactor.
RED+GREEN combined: regression test case_recordings_honours_show_closed_for_closed_case
in server/tests/case_page_test.rs seeds a closed case with a recording and
asserts the route returns 200 with the recording listed, not 404. Full
case_page_test suite green (18 passed); cargo clippy clean; cargo fmt clean.
closes#17
The server-rendered pages worked on desktop but were near-unusable on a
phone: no viewport meta tag (mobile browsers laid out in a ~980px canvas
and zoomed out, rendering everything tiny) and a fixed `max-width: 900px`
desktop body with zero responsive breakpoints.
Fix is CSS-only and purely additive:
- Add `<meta name="viewport" content="width=device-width, initial-scale=1">`
to all four page templates (each owns its own <head> — askama uses
import/include here, no shared base layout, so the tag lands in 4 places).
- Append a single `@media (max-width: 640px)` block to the three
desktop-width pages (my_cases, case_recordings, case_page): case rows
and action bars reflow to a single column, the audio player goes
full-width, and primary touch targets (delete/reopen/play) get a 44px
minimum. login.html already has a 360px column layout, so it needs the
viewport tag only.
Desktop layout is preserved by construction: a `max-width: 640px` query
never matches at >=900px, so the desktop render is byte-identical. The
diff adds lines only — no existing rule is touched.
Breakpoint chosen at 640px (well below the 900px body width) so all
portrait phones, including the ~390px target, fall under it while
tablets keep the desktop layout.
New integration test server/tests/responsive_test.rs pins the two
textual acceptance criteria that survive without a headless browser:
every page ships the viewport meta, and the three desktop-width pages
ship an @media breakpoint. The visual criteria (operable at 390px, 44px
touch targets) are CSS-driven and verified by eye — deliberately not
covered by a browser-automation dependency.
Verified: cargo test -p doctate-server --test responsive_test (4/4
green); web/login/case_page regression suites green; diff is additive.
closes#10
Introduce a `Badge` enum and a `badge_for` function to consolidate
case status logic. This replaces multiple boolean flags (`analyzing`,
`has_failed_recording`, `analysis_required`) with a single, more
expressive `badge` field.
The HTML template has been updated to use this new badge system,
removing old status classes and logic. The `scan_user_cases` function
no longer needs the `worker_busy` argument as the analysis state is
now derived directly from the `auto_trigger` state.
This change improves code clarity and maintainability by centralizing
status determination and reducing redundant boolean flags.
Introduces a visual indicator for stale analysis on the case page and a
new section on the "My Cases" page to highlight cases requiring
analysis. This improves user awareness of data currency and pending
actions.
Failure markers now carry finish_reason and usage tokens (prompt /
completion / reasoning) pulled from the chat-completions response, so
the case-page "Technische Details" block can show why an analysis
truncated — e.g. gpt-oss-120b hitting max_completion_tokens with the
chain-of-thought before producing any visible answer.
The worker also emits a new CaseEventKind::AnalysisFailed after writing
the marker, so subscribed browsers reload immediately instead of leaving
the page on the "Wird analysiert …" placeholder.
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
Introduce `display_name` field to `User` struct for human-readable names
in the UI.
This falls back to the user's `slug` if not provided.
Update `AuthenticatedWebUser` and `MyCasesTemplate` to use this new
field for rendering.
Add tests for parsing `display_name` and ensure fallback logic works
correctly.
Update `users.toml.example` to document the new field.
An empty `backend` form field now correctly falls back to the default. A
non-empty field submitted by a non-admin user is rejected with a 403
error, preventing potential tampering or issues arising from stale
sessions after role changes.
Replace the single [llm] block in settings.toml with a curated catalog
of LLM "backends" (provider + model + sampling params + system prompt)
in server/src/analyze/backend.rs. Two backends ship initially:
gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b
(uses response_format: json_schema to sidestep the <|eot_id|> leak).
The case page now renders one submit button per available backend; the
clicked button's name=backend value rides through AnalysisInput.backend_id
to the worker, which looks it up via find_backend() with default fallback.
A submitted unknown backend id is rejected as 400. .analysis_failed.json
records the failed backend and the failure banner surfaces its label.
The API key now comes from IONOS_API_KEY (env), not settings.toml; the
[llm] section is read into a discarded field so old configs still load.
Backends without a satisfied requires_api_key are filtered from the UI
(any_backend_available()). Three end-to-end tests that mocked the LLM
endpoint via settings.llm.url are #[ignore]'d with a clear note — they
need per-test backend injection (Arc<Vec<LlmBackend>> through the worker)
before they can be re-enabled.
When an LLM analysis fails, a `.analysis_failed.json` marker is created.
If no document exists yet and the auto-trigger is blocked by this
marker,
the case page must display a banner. This banner provides the failure
reason and a button to retry the analysis, ensuring users are not left
in a dead-end state.
The `read_failure_marker` function is made public to allow the web layer
to access this failure information. The `CasePageTemplate` is updated to
include `analysis_failed` data, which conditionally renders the new
`.failure-banner` HTML.
This change prevents cases from becoming unrecoverable due to transient
LLM errors.
Includes a button for administrators to copy all transcripts and the
document markdown to the clipboard.
The data is embedded as JSON in a script tag and pre-sanitized to
prevent
`</script>` tag injection.
The JavaScript handles copying to the clipboard using the modern
Clipboard API,
with a fallback for older browsers.
The `RecordingView` struct now includes `gain_db` to represent
replay-gain. This value is read from the recording's metadata (`.json`
sidecar) and passed to the frontend.
The JavaScript in `case_recordings.html` uses this `data-gain-db`
attribute to apply replay-gain using the Web Audio API. This ensures
consistent playback loudness across different recordings. The
implementation uses a shared `AudioContext` to manage resources
efficiently and handles cases where the browser might not support
`AudioContext`.
Additionally, the audio recording on Wear OS now uses
`MediaRecorder.AudioSource.VOICE_RECOGNITION` instead of `MIC`. This
leverages platform noise and echo cancellation, aiming for a cleaner
signal before server-side gain adjustment.
Replaces the `.transcript.txt` sidecar with a structured `.json` file
for recording metadata. This change consolidates transcript text,
duration, and other potential metadata into a single, extensible JSON
object.
This also refactors the `TranscriptState` enum to better represent the
on-disk state (absence of file means pending) and the in-memory
representation. The `Transcript` enum now specifically models the
terminal outcomes of the transcriber (`Silent` or `Content`).
The commit includes updates to documentation, data structures, path
handling, and various tests to align with the new metadata format.
- Replace native confirm() in the recording list with an inline
two-step confirm row; hide the player via visibility:hidden
while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
instead of relying on the Referer header (which silently fell
back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
source page (case detail vs. case list) is preserved reliably,
with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
adjust analyze/reset redirect expectations to the new
case-detail fallback.
Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.
Shape:
- New askama macro partials/csrf_field.html renders the hidden
input; 3 templates import + {% call csrf::field(csrf_token) %}
inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
session once during extraction) so render handlers don't need a
second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
CaseRecordingsTemplate) gain the field; render sites pass
user.csrf_token through.
Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
a hidden input — catches anyone adding a new form without the
macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
with token → 303. Catches drift between rendered and accepted
token formats.
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.
Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.
Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
Replaces `analysis_preview` with `analysis_html` to display the full
markdown-rendered document content instead of just a plain-text preview.
This allows for richer content presentation and enables client-side
expansion for detailed views.
The HTML template has been updated to include a new `.analysis`
container that handles the expand/collapse functionality using
JavaScript. This approach avoids server-side truncation and relies on
CSS for presentation.
Introduce a dedicated case-link class for clickable case titles.
Enhance the line1 div to use flexbox for better alignment of time,
title, and recordings count.
Add a separate link for recordings, improving the structure and user
experience of the case list.
Introduce `count_closed_by_date` and `most_recent_date_label` to
efficiently count closed cases for display purposes when `show_closed`
is false.
Modify `group_by_utc_date` to accept a `closed_extra` map, allowing it
to correctly calculate both open and total case counts per day.
Update the `my_cases.html` template to display the new
`open_count/total_count` format for case groups.
Introduce a `preview_lines` setting in `users.toml` to control the
number of visible lines for the analysis preview in the case list. This
feature extracts the first paragraph of the `document.md` file, strips
markdown formatting, and displays it, capped by the configured
`preview_lines`. The actual line clamping is handled client-side via CSS
`line-clamp`.
- my_cases.html: `.status-badge.open` from red (#e24a4a) to blue (#4a90e2),
matching case_page.html (which was already blue) and the reopen-btn
accent. Resolves the cross-template inconsistency and frees red for
the Fehler-Badge.
- Both templates: `.status-badge.fehler` from deep red (#c00) to the
established system red (#e24a4a), already used by `.bulk-bar.purge`
and `.delete-btn:hover`.
- Badge label capitalised: "fehler" → "Fehler" (user-facing string
only; CSS class stays `.fehler`).
Before, every Whisper error (5xx, timeout, Minerva down, corrupt audio,
4xx) renamed `<ts>.m4a` to `<ts>.m4a.failed` uniformly, turning transient
outages into permanent sackgassen that only a manual admin reset could
undo. Cases whose every recording was `.m4a.failed` stuck without a
persisted oneliner state; the UI masked them via a fallback in
`compute_oneliner_display`.
The core insight: a recoverable error is not an error. Transient Whisper
failures now leave the audio as plain `.m4a` so the existing page-load
heal (`enqueue_pending_for_user`) re-enqueues it on the next refresh —
which is exactly what happens when Minerva comes back. No new sidecar,
no retry count, no scheduler.
Changes:
- `WhisperError::is_transient` classifies `Http`, 5xx, 408, 429 as
transient; `Io` and other 4xx as permanent.
- Transcribe worker: transient → info log + continue (audio stays .m4a);
permanent → `mark_failed` as before.
- `has_any_transcript` counts `.m4a.failed` as terminal, so
`update_oneliner` runs for failed-only cases and settles
`OnelinerState::Empty` (analogous to the silent-only fix in 4531f85).
- New verdrängender `fehler`-Badge (#c00) in case list and detail when
at least one `.m4a.failed` exists; recording-level message shortened
to plain "Transkription fehlgeschlagen".
Tests:
- New integration: transient 503 leaves `.m4a` intact, heal recovers.
- New integration: `.m4a.failed`-only case settles to
`OnelinerState::Empty` without calling Ollama.
- New unit: `is_transient` table test across relevant status codes.
- New unit: `has_any_transcript` returns true for `.m4a.failed`-only
case and false for pending-only case.
- Existing worker test retargeted from 500 to 400 and renamed; added
companion `worker_leaves_m4a_intact_on_transient_whisper_error`.
Introduce `TranscriptState { Pending, Silent, Content }` in
doctate-common as the canonical three-way state of a recording's
`.transcript.txt` sidecar. Previously each call-site projected the
raw `Option<String>` / `.exists()` onto its own 2-state view and the
projections disagreed: the UI treated a 0-byte silent transcript like
`Content` while the oneliner worker treated it like `Pending`,
leaving silent-only cases stuck on "generiere Titel …" forever with
no persisted oneliner.json.
`update_oneliner` now settles a silent-only case to
`OnelinerState::Empty` when no `Content` transcript exists and no
recording is still `Pending`, so the UI resolves to "unbenannt" and
recovery treats it as terminal.
Single reader: `paths::read_transcript_state`. All call-sites
(scan_recordings, compute_oneliner_display, has_pending_recordings,
all_transcripts_joined, read_recordings, enqueue_pending_for_user,
scan_m4as, cases_needing_oneliner_in, case_recordings.html) go
through the same typed abstraction and must handle `Silent` via
exhaustive match.
Regression tests:
- silent_case_empty_test: heal path settles silent-only case to
Empty without calling Ollama
- case_page_silent_only_shows_empty_not_generating: UI renders
"unbenannt", not "generiere Titel …"
Clicking close/reopen or any bulk form submits a POST and follows the
303 to the listing, which is a fresh navigation — browsers do not
restore scroll on navigation, only on reload(). A user scrolled to
row 30 of a long list loses focus and has to scroll back manually.
Fix: small JS on my_cases.html that stashes window.scrollY in
sessionStorage before every form submit and restores it on the next
page load. Silent no-op when sessionStorage is blocked (strict
privacy mode), so the feature is purely additive.
No server changes — kept the redirect URLs clean. An earlier iteration
with a #case-<uuid> anchor on the redirect turned out to race with
the explicit scrollTo on any page where the referer scrollY was near
0; dropping the anchor made the JS the sole source of truth.
Three UX polish items for the show-closed listing, grouped because
they are the same root problem in three surfaces:
* handle_reopen_case now redirects via resolve_return_path(&headers)
instead of hard-coding /web/cases, so a reopen from the
?show_closed=1 listing stays in that view. Matches the pattern
already used by analyze/reset/delete-recording.
* handle_close_case now redirects via the new helper
resolve_list_return_path(&headers), which wraps resolve_return_path
and additionally collapses a /web/cases/{uuid} detail path to
/web/cases while keeping the query string. A close from the detail
page previously would have tried to redirect back to the now-404
detail, or when triggered from ?show_closed=1 would have dropped
the query.
* Closed cases in my_cases.html now render the checkbox,
Analysieren/Neu analysieren and Reset controls in disabled state
instead of being hidden. Layout stays consistent with mixed-state
listings, and the user can see what actions would be available
after reopen. The handlers remain the authoritative guard via
locate_case_or_404 -> 404 for closed cases, so the HTML disabled
is a hint only.
Adds four unit tests for resolve_list_return_path and two integration
tests for the new redirect behaviour (close from listing with query,
close from detail with query).
GET /web/cases now honours ?show_closed=1: the toggle link flips the
listing between open-only (default) and open+closed (muted styling,
grey "geschlossen" badge, countdown "wird in N Tagen entfernt" when
auto_delete_days > 0). Each closed row shows a Lucide rotate-ccw
reopen button in the same slot where the close button sits on open
cases; selection checkboxes are suppressed so bulk-close cannot touch
closed cases.
GET /web/cases/:id honours ?show_closed=1 too: without the query a
closed case still 404s (keeps the default listing clean), with it the
detail page renders the reopen form in the header. The case_page
template swaps the icon based on is_closed.
The purge-closed bulk button from the earlier commit finally gets a
UI: it appears below the listing when show_closed=1 is active AND at
least one closed case is visible, with a JS confirm() + the
server-side confirm=yes guard. Two integration tests (listing +
detail) cover the happy path.
- POST /web/cases/:id/delete renamed to /close; emits CaseClosed.
- New POST /web/cases/:id/reopen removes the marker; emits CaseReopened.
- Bulk action "delete" renamed to "close"; the shared closed_at timestamp
still groups the selection for future UI features.
- POST /web/cases/undo-delete removed along with summarize_latest_close_group,
latest_close_timestamp and restore_close_group. Per-case reopen makes
the group-undo banner obsolete.
- CaseEventKind::CaseDeleted -> CaseClosed, CaseRestored -> CaseReopened.
- Templates updated to Lucide trash-2 icon, German label "Fall schliessen".
- Tests migrated; delete_watermark_test.rs moved to close_watermark_test.rs.
This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
Adjust the `justify-content` of the player to align items to the right,
and limit the `max-width` of the seek bar to prevent overflow and
improve layout on smaller screens.
Introduced a new macro `render` in `partials/oneliner.html` to handle
the display of the oneliner for cases. This macro encapsulates the logic
for rendering the oneliner, including states for ready, empty, error,
pending, and generating.
The `case_page.html` and `my_cases.html` templates are updated to use
this new macro. This refactoring centralizes the oneliner rendering
logic, making it more maintainable and consistent across different
views.
Additionally, the `OnelinerDisplay` enum is now used in
`CasePageTemplate` and `MyCasesTemplate` to represent the different
states of the oneliner, improving type safety and clarity.
Introduce `Generating` state and clarify `Empty` and `Missing` states.
The `Pending` state now specifically refers to transcription being in
progress.
The `Generating` state covers the period after transcription is complete
but before the LLM has produced the final oneliner state, or when a new
state needs to be generated due to new recordings.
The `Missing` state is collapsed into `Empty` as the UI treats them
identically when no title is expected.
The oneliner is now stored as `OnelinerState` enum which can represent
three states: `Ready`, `Empty` or `Error`. This allows the client to
differentiate between a case with no medical content and a case where
the oneliner generation failed.
The `OnelinerState` enum is serialized to JSON and stored in
`oneliner.json` file. The `OnelinerEntry` struct has been updated to
reflect this change.
The client-desktop application has been updated to handle the new
`OnelinerState` enum and display appropriate UI elements for each state.
The server-side code has also been updated to read and write the
`OnelinerState` enum, and to handle the new file format.
The `reset_case_artefacts` function in
`server/src/routes/case_actions.rs` has been updated to remove
`oneliner.txt` and create `oneliner.json` instead.
The tests have been updated to reflect these changes.
Adjusted layout for better readability and alignment. Increased heading
size and centered it. Updated input and button padding for consistency
and improved visual hierarchy. Added a subtle color and increased
font-size for labels.
Add `recorded_at_iso` and `time_hms_utc` to `CasePageTemplate` to
display the timestamp of the most recent recording.
Introduce a new partial `partials/time_format.js` for consistent date
and time formatting in the browser.
Update templates to use the new partial for rendering timestamps and
group headings.
Introduces a `resolve_return_path` function to determine the redirect
target after a case action. This function prioritizes the `Referer`
header for a more contextual redirect, falling back to the default case
list page if the header is absent or invalid.
Additionally, this commit refactors the UI elements for actions on the
case detail and case list pages, improving organization and clarity.
Introduces a new module `auto_trigger` responsible for opportunistically
initiating LLM analysis jobs. This mechanism scans case directories for
new or updated recordings and transcripts, automatically creating and
queuing analysis jobs when appropriate.
Key components:
- `evaluate_case`: Pure function to determine if a case is ready for
analysis based on recording status, transcriptions, document
modification times, and existing job/failure markers.
- `try_enqueue`: Orchestrates the analysis job creation and queuing
process if `evaluate_case` returns `AutoDecision::Enqueue`.
- `try_enqueue_all_for_user`: Iterates through all user cases to trigger
`try_enqueue` for eligible ones.
- `write_failure_marker`/`remove_failure_marker`: Handles persistent
recording of analysis failures and their cleanup.
This feature aims to reduce manual intervention by automatically
analyzing new or changed case data.
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.
Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
reloads.
- Integration points: Workers and route handlers now emit events when
relevant state changes occur.
This commit enables serving audio files via HTTP Range requests. This is
crucial for allowing HTML5 audio players to seek to specific positions
within an audio file without re-downloading the entire file.
The changes include:
- Modifying `handle_audio` in `server/src/routes/web.rs` to parse
`Range` headers.
- Implementing `serve_range` to handle partial content responses.
- Adding a `parse_range` helper function.
- Updating tests to verify range request functionality.
- Adding `ACCEPT_RANGES: bytes` header to indicate support for range
requests.
- Storing recording duration in a sidecar file for faster UI rendering.
- Enhancing the HTML template to support a custom audio player with
seeking.
Introduces a toggle in the web UI that allows administrators to switch
between a standard view and an "admin view". This admin view exposes
additional administrative elements that are hidden in the standard view.
The toggle state is persisted in local storage, allowing the user's
preference to be remembered across sessions. Non-administrator users
will not see the toggle.
feat: Add admin view toggle to web UI
Introduce a client-side toggle for an "admin view" in the web UI. This
allows administrators
to selectively hide or show elements intended only for administrative
purposes. The toggle
state is persisted in local storage for a persistent user experience.
The implementation involves:
- Adding a checkbox element to the header of the case pages and my cases
list.
- Using CSS to conditionally hide elements with the `admin-only` class
when the admin view is off.
- Implementing JavaScript to manage the toggle's state, update local
storage, and apply the
`admin-view-off` class to the `<html>` element.
- Updating relevant templates (`case_page.html`, `case_recordings.html`,
`my_cases.html`)
to include the toggle and the `admin-only` class where appropriate.
- Adding unit tests to verify the visibility of the toggle for admins
and non-admins.
Passes an `is_admin` flag to the `CaseRecordingsTemplate` and
`case_page.html`.
This flag determines whether the full case ID is displayed in the meta
section
for administrative users. It also influences the header display on the
case page
to prioritize the oneliner when available.
This commit restructures the web interface for case details. The
previous `/web/cases/{case_id}` route, which previously showed both the
case summary and its recordings, has been split into two distinct pages:
- `/web/cases/{case_id}`: This page now displays the overall case
information, including the document if available.
- `/web/cases/{case_id}/recordings`: This new page is dedicated to
listing and displaying individual recordings within a case.
This change improves the organization and clarity of the web UI,
allowing for more focused views of case data. Additionally, the
`case_detail.html` and `document.html` templates have been removed as
their functionality is now handled by the new `case_page.html` and the
upcoming document rendering logic. The `cases.html` template has also
been removed, indicating a shift towards more granular page views.
This commit separates the case detail page into two distinct routes:
`/web/cases/{case_id}` for the main case information and document, and
`/web/cases/{case_id}/recordings` for a dedicated view of audio files
and their transcripts.
This change improves the organization and clarity of the case viewing
experience by segmenting the related but distinct information into their
own UI sections.
Introduce `last_recording_at` to `OnelinerEntry` and use it as the
primary sort key for cases. This ensures that cases with recent
dictation activity are prioritized, even if their initial creation date
is older.
The logic for determining a case's activity has been updated to consider
the most recent `.m4a` file's modification time (`last_recording_at`),
falling back to `updated_at` or `created_at` if necessary.
This change also refactors the timestamp formatting and handling within
the web interface to correctly display and sort cases based on their
actual last activity time, improving user experience and data relevance.
The `now_rfc3339` function is updated to strip sub-second precision for
consistent filename generation.
This commit introduces a new module `analyze::render` to handle the
rendering of Markdown content produced by the LLM into safe HTML.
The LLM output now includes a custom `==text==` highlighting convention
to indicate sections that require doctor review due to potential
transcription errors or incomplete information. This highlighting is
converted into `<mark>` tags in the final HTML.
To ensure security, all LLM-generated Markdown is first HTML-escaped.
This prevents any malicious HTML or script injection from being executed
in the browser. Only the custom `<mark>` tags are preserved as
functional HTML elements.
The process is as follows:
1. The raw Markdown from the LLM is processed.
2. All HTML special characters (`<`, `>`, `&`, `"`, `'`) are escaped.
3. The `==text==` highlights are replaced with `<mark>text</mark>`.
4. The resulting string is parsed as Markdown by `pulldown-cmark`.
5. The parsed Markdown is converted to HTML, which is then safe to
inject into the Askama template using the `|safe` filter.
The `Cargo.toml` and `Cargo.lock` files have been updated to include the
`pulldown-cmark` dependency. The `document.html` template has been
modified to use a `div` with the class `doc-content` instead of a `pre`
tag, allowing the rendered HTML to be displayed correctly. The
`handle_document_view` function now calls the new `md_to_html` rendering
function.
Introduce a new "reset" action for bulk operations and a dedicated
endpoint for individual case resets. These features allow administrators
to revert a case to its raw audio state by deleting derived artifacts
like transcripts, analysis input, and documents. The functionality also
includes renaming `.m4a.failed` files back to `.m4a` to re-trigger
transcription.
This commit also:
- Adds a `reset_case_artefacts` helper function to `case_actions.rs`.
- Implements the `handle_reset_case` endpoint.
- Adds the "Reset" button to the UI for administrators.
- Includes unit and integration tests to verify the new functionality
and access control.
The versioning of analysis input and document files
(`analysis_input_v{N}.json`, `document_v{N}.md`) has been removed. All
analysis inputs will now use `analysis_input.json` and generated
documents will use `document.md`.
This simplifies file management, as there's no longer a need to track
and manage multiple versions of these files within a case directory. The
analysis worker will now overwrite the existing `document.md` if it
exists, ensuring that the latest analysis result is always present. The
`version` field has also been removed from `AnalyzeJob` and
`AnalysisInput`.