Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0516bedc | ||
|
|
e5ca78ba41 | ||
|
|
3c4f8de3a4 | ||
|
|
dd91b83c27 | ||
|
|
815e7307d1 | ||
|
|
9af6fedbe0 | ||
|
|
6016745297 | ||
|
|
8a355b637f | ||
|
|
8889a4e351 |
No files matched your search
@@ -0,0 +1,79 @@
|
|||||||
|
# Dashboard tables
|
||||||
|
|
||||||
|
How every data table on the dashboard is laid out, and the rules a new table
|
||||||
|
must follow so it never grows a horizontal scrollbar on a desktop screen.
|
||||||
|
Everything here is plain CSS in `src/web/tailwind.css` plus four cell classes;
|
||||||
|
there is no table library and no JavaScript involved.
|
||||||
|
|
||||||
|
## The problem this solves
|
||||||
|
|
||||||
|
A table used to be `min-width: max-content` inside a `.scroll-x` wrapper, and
|
||||||
|
most cells were `white-space: nowrap`. Any table whose natural width exceeded
|
||||||
|
the page column (80rem, ~1355px at the site's 110% scale) became a horizontal
|
||||||
|
scroller, even on a 2560px monitor, and the columns that mattered were hidden
|
||||||
|
behind the scrollbar. Wrapping columns were squeezed to their minimum while the
|
||||||
|
nowrap ones kept everything.
|
||||||
|
|
||||||
|
## The three rules
|
||||||
|
|
||||||
|
1. **Tables get the whole viewport, everything else keeps the page column.**
|
||||||
|
`.dashboard` is a CSS grid with two named column spans: `content` (at most
|
||||||
|
80rem minus the page gutters, exactly what the header uses) and `wide` (the
|
||||||
|
viewport minus the same gutters). Every direct child sits in `content`; a
|
||||||
|
direct-child `.scroll-x` sits in `wide`. The table inside a wide wrapper is
|
||||||
|
`width: auto` (shrink-to-fit) with `min-width` equal to the content column
|
||||||
|
and `max-width: 100%`, centred. A table that fits the page column therefore
|
||||||
|
looks exactly as before; a table that needs more grows, centred, up to the
|
||||||
|
viewport edge; the page itself never scrolls sideways.
|
||||||
|
|
||||||
|
2. **Columns wrap before the table scrolls.** The `min-width: max-content` rule
|
||||||
|
is gone. A table shrinks by wrapping its text columns down to their floors,
|
||||||
|
and only when the floors alone no longer fit (phones, mostly) does the
|
||||||
|
`.scroll-x` wrapper scroll. Headers always wrap.
|
||||||
|
|
||||||
|
3. **A cell declares what it holds, and nothing else decides its width.**
|
||||||
|
There are exactly four cell classes:
|
||||||
|
|
||||||
|
| class | use it for | behaviour |
|
||||||
|
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| *(none)* | short prose, names, tokens, badges and badge lists | wraps at spaces (a `.badge` is nowrap on its own, so lists wrap between badges) |
|
||||||
|
| `num` | numbers, money, counts | right-aligned tabular figures; `td.num` never wraps, `th.num` may |
|
||||||
|
| `cell-tight` | timestamps, dates, ids, a single short token that must not split | `white-space: nowrap`. Nothing longer than ~20 characters |
|
||||||
|
| `cell-wrap` | titles, URLs, notes, messages, anything free-form | floor 10rem, ceiling 32rem, `overflow-wrap: anywhere` |
|
||||||
|
|
||||||
|
Anything long that must not wrap gets an inner block with a Tailwind clamp
|
||||||
|
(`<div class="line-clamp-2" title="…">`) inside a `cell-wrap` cell; the cell
|
||||||
|
ceiling bounds the column, the clamp bounds the row height. Do not put
|
||||||
|
`truncate`/`line-clamp-*` on a `td` itself (a table cell cannot be a
|
||||||
|
`-webkit-box`), and do not use `table-fixed` on a page-level table (fixed
|
||||||
|
layout needs a definite width and is silently ignored with `width: auto`).
|
||||||
|
|
||||||
|
## Checklist for a new table
|
||||||
|
|
||||||
|
- Wrap it in `<div class="scroll-x">` and make that wrapper a **direct child** of `<section class="dashboard">` if the
|
||||||
|
table has more than a handful of columns. Tables inside `.card`, `.cards` or
|
||||||
|
`details` stay inside their box and just wrap/scroll there.
|
||||||
|
- Let the page scroll. A table that is the page's feature (paginated lists,
|
||||||
|
anything that is the last thing on the page) shows every row at full height;
|
||||||
|
a scrolling island inside a scrolling page just fights the wheel. Add `tall`
|
||||||
|
(70vh cap, own scrollbar) only when the table is a side feature with real
|
||||||
|
content below it, as the profile page's version history is.
|
||||||
|
- Give every `td` one of the four classes above according to its content.
|
||||||
|
When in doubt, leave it unclassed: wrapping is the safe default.
|
||||||
|
- Never `cell-tight` a name, a title, a reason sentence or a free-text field.
|
||||||
|
- Check the page at 390, 1280 and 2560px. At 1440 and above the wrapper's
|
||||||
|
`scrollWidth` must equal its `clientWidth`; at 390 a wide table is allowed to
|
||||||
|
scroll. A table's floor is the sum of its nowrap columns plus 10rem per
|
||||||
|
`cell-wrap` column: the 13-column articles table bottoms out near 1270px, so
|
||||||
|
it still scrolls a little on a 1280px viewport. Tables inside a `.card`
|
||||||
|
cannot grow past the card (20rem minimum), so a card table with more than
|
||||||
|
four numeric columns (run page: provider usage) scrolls inside its card at
|
||||||
|
1280px; move such a table to page level if that matters.
|
||||||
|
|
||||||
|
## Measuring
|
||||||
|
|
||||||
|
`docs/plans/briefs/dashboard-tables/measure.mjs` logs in as the dev-seed admin,
|
||||||
|
visits every dashboard page at several widths and prints, per page, whether the
|
||||||
|
document overflows and which table wrappers scroll. Run it against a seeded
|
||||||
|
dev server (`cargo run --example seed_dev_db -- ./dev`) before and after a CSS
|
||||||
|
change; the "scrolling" count for widths ≥ 1280 should be zero.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Shared brief — dashboard tables (2026-09-20)
|
||||||
|
|
||||||
|
The operator sees horizontal scrollbars on most dashboard tables, even on an
|
||||||
|
ultrawide monitor, because every table is capped at the 80rem page column and
|
||||||
|
`min-width: max-content` forbids wrapping. The fix is a small, reusable table
|
||||||
|
framework described in **`docs/dashboard-tables.md` — read it first; it is the
|
||||||
|
spec.** Two tasks run in parallel in separate git worktrees:
|
||||||
|
|
||||||
|
- `01-css.md` — the CSS side (`src/web/tailwind.css`, rebuilt `app.css`).
|
||||||
|
- `02-templates.md` — the template audit (every `td` gets the right cell class).
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- Work only in the worktree named in your brief. **Never touch the main checkout**
|
||||||
|
at `/home/thallada/workspace/the-daily-epub`. **Do not `git commit`** (the sandbox
|
||||||
|
cannot write `.git/worktrees`); leave the work in the tree, the orchestrator
|
||||||
|
commits.
|
||||||
|
- Styling lives in **`src/web/tailwind.css`** (Tailwind v4, `@apply` inside
|
||||||
|
`@layer components`, semantic colour tokens). After every CSS change run
|
||||||
|
`npm run css` (writes `src/web/static/app.css`, which the binary embeds with
|
||||||
|
`include_str!`). `npm run css:check` must print no diff at the end.
|
||||||
|
- CSP forbids inline `<style>`, `<script>` and `style=""` attributes. No new JS,
|
||||||
|
no new dependencies, no table library: this is plain CSS + four cell classes.
|
||||||
|
- Keep every hook `app.js` relies on: `table[data-filter]`, `.table-filter`,
|
||||||
|
`input[data-table-filter]`, `.scroll-x` (the filter script does
|
||||||
|
`table.closest(".scroll-x")`), `form[data-confirm]`, `details[id]`, `[data-refresh]`.
|
||||||
|
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the tests
|
||||||
|
green (`cargo test --lib web::` while iterating, one full `cargo test` at the
|
||||||
|
end). **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is
|
||||||
|
forbidden; ignore every test that fails with `PermissionDenied`/bind errors
|
||||||
|
(about 17 pre-existing ones). Do not "fix" them.
|
||||||
|
- 4 cores / 7 GB shared with another agent building Rust at the same time: one
|
||||||
|
cargo process at a time, be patient.
|
||||||
|
- KISS / YAGNI: no config knobs, no per-table width tables, no JS resizing, no
|
||||||
|
responsive card-ification of rows. Where the brief and the code disagree,
|
||||||
|
follow the code and note the deviation in your handoff.
|
||||||
|
- Finish by writing `docs/plans/briefs/dashboard-tables/handoff-<task>.md`:
|
||||||
|
what changed (file by file), deviations, what you verified and how, anything
|
||||||
|
left open.
|
||||||
|
|
||||||
|
## Previewing (optional — the sandbox usually cannot bind a port)
|
||||||
|
|
||||||
|
Seed a throwaway database with `cargo run --example seed_dev_db -- ./dev`
|
||||||
|
(git-ignored) and serve it with
|
||||||
|
`DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) DAILY_EPUB_SERVER__BIND=127.0.0.1:<port> cargo run -- --config ./dev/config.toml serve`.
|
||||||
|
`measure.mjs` in this directory (needs `playwright` on the module path; a copy
|
||||||
|
lives at `~/.npm/_npx/705bc6b22212b352/node_modules`) prints per page and width
|
||||||
|
whether the document overflows and which table wrappers scroll. If you cannot
|
||||||
|
bind, rely on the router tests and say so; the orchestrator screenshots every
|
||||||
|
page at 390/768/1280/1920/2560 after merging.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Task 01 — table framework CSS
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-tables-css` (branch `tables-css`).
|
||||||
|
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only `src/web/tailwind.css`
|
||||||
|
and the rebuilt `src/web/static/app.css` change in this task (plus your handoff).
|
||||||
|
The template audit happens in a sibling worktree; do not edit templates.
|
||||||
|
|
||||||
|
## 1. `.dashboard` becomes a content grid
|
||||||
|
|
||||||
|
Replace `.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 … sm:px-6; }` with a
|
||||||
|
grid that keeps the page column pixel-identical to the header
|
||||||
|
(`mx-auto max-w-7xl px-4 sm:px-6` = content width `80rem - 2 * gutter`, centred):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.dashboard {
|
||||||
|
--gutter: 1rem;
|
||||||
|
@apply my-8 grid pb-6 font-sans text-base leading-normal;
|
||||||
|
grid-template-columns:
|
||||||
|
[full-start] var(--gutter)
|
||||||
|
[wide-start] 1fr
|
||||||
|
[content-start] minmax(0, calc(80rem - 2 * var(--gutter)))
|
||||||
|
[content-end] 1fr
|
||||||
|
[wide-end] var(--gutter)
|
||||||
|
[full-end];
|
||||||
|
}
|
||||||
|
@media (width >= 40rem) { .dashboard { --gutter: 1.5rem; } } /* Tailwind `sm` */
|
||||||
|
.dashboard > * { grid-column: content; min-width: 0; }
|
||||||
|
.dashboard > .scroll-x { grid-column: wide; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `.dashboard > * + * { mt-4 }` and every other `.dashboard …` rule as they
|
||||||
|
are (margins on grid items work). Verify in the templates that nothing relied
|
||||||
|
on the old padding: `.save-bar` uses `-mx-4 sm:-mx-6` to bleed to the edges;
|
||||||
|
under the grid it still overflows its `content` area into the gutter columns
|
||||||
|
by the same amount, so leave it unless you see a problem. The `sm` breakpoint
|
||||||
|
is `40rem` in Tailwind v4 and `@media` rem is always 16px-based, so the query
|
||||||
|
above matches `sm:` exactly.
|
||||||
|
|
||||||
|
Prototype measured in Chromium and Firefox at 390/1280/2560: `h1` left edge and
|
||||||
|
width identical to today, no document overflow, table centred and wider than
|
||||||
|
the column only when its content needs it.
|
||||||
|
|
||||||
|
## 2. Tables shrink by wrapping; page-level tables may grow past the column
|
||||||
|
|
||||||
|
- Delete `.scroll-x > table { min-width:max-content; }`.
|
||||||
|
- Add, for the wide wrapper only:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.dashboard > .scroll-x > table {
|
||||||
|
width: auto; /* shrink-to-fit */
|
||||||
|
min-width: min(100%, calc(80rem - 2 * var(--gutter)));
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A table narrower than the page column fills the column as today; a wider one
|
||||||
|
grows, centred, to the viewport edge; the wrapper scrolls only past that.
|
||||||
|
Tables that are not direct children (cards, disclosures, signal tables inside
|
||||||
|
cells) keep `w-full` from the base rule and simply wrap, then scroll.
|
||||||
|
|
||||||
|
## 3. Cell vocabulary (exactly four classes, see the doc's table)
|
||||||
|
|
||||||
|
- `.num` → `@apply text-right tabular-nums;` and `td.num { @apply whitespace-nowrap; }`.
|
||||||
|
Header cells with `.num` may wrap ("cache write", "matched articles").
|
||||||
|
- `.cell-tight` unchanged (`whitespace-nowrap`).
|
||||||
|
- `.cell-wrap` → `@apply min-w-[10rem] max-w-[32rem] whitespace-normal; overflow-wrap:anywhere;`
|
||||||
|
(ceiling raised from 24rem so titles get a single line when the screen has
|
||||||
|
room; `max-width` on a cell is honoured by Chromium and Firefox for the
|
||||||
|
column's max-content contribution — measured).
|
||||||
|
- `th` keeps wrapping (no nowrap); keep the sticky `thead`.
|
||||||
|
- `td > pre.preview { max-w-md }` and `.rating-cell` rules stay.
|
||||||
|
|
||||||
|
## 4. Small things to keep honest
|
||||||
|
|
||||||
|
- `.prose-body table` (reader pages) keeps its own rules; do not touch.
|
||||||
|
- Put a short comment above the table rules pointing at `docs/dashboard-tables.md`.
|
||||||
|
- `npm run css`, then `cargo build` (the binary embeds app.css) and
|
||||||
|
`cargo test --lib web::`. Router tests assert on markup, not on these rules,
|
||||||
|
so nothing should break; if something does, say what and why in the handoff.
|
||||||
|
- If you can run the dev server + `measure.mjs`, do it and paste the ≥1280 lines
|
||||||
|
for `/dashboard/articles`, `/dashboard/runs` and `/dashboard/runs/1` into the
|
||||||
|
handoff. With unchanged templates some tables will still scroll at 1280 —
|
||||||
|
that is expected until the template audit lands — but at 2560 the articles
|
||||||
|
and candidates tables must fit without scrolling.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Task 02 — template audit: every cell says what it holds
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-tables-tpl` (branch `tables-tpl`).
|
||||||
|
Read `00-shared.md` and `docs/dashboard-tables.md` first. Only files under
|
||||||
|
`src/web/templates/` change in this task (plus your handoff). The CSS lands in a
|
||||||
|
sibling worktree; assume the four cell classes behave exactly as the doc's
|
||||||
|
table says (`cell-wrap` floor 10rem / ceiling 32rem with `overflow-wrap:anywhere`,
|
||||||
|
`cell-tight` nowrap, `td.num` nowrap right-aligned, unclassed cells wrap at
|
||||||
|
spaces, `th` always wraps, page-level tables grow past the page column when
|
||||||
|
their content needs it).
|
||||||
|
|
||||||
|
Go through every `<table>` in `src/web/templates/dashboard/*.html`,
|
||||||
|
`src/web/templates/_candidate_row.html` and `src/web/templates/_signals_table.html`
|
||||||
|
and apply these rules to each `td`:
|
||||||
|
|
||||||
|
1. **`cell-tight` only on timestamps, dates, ids and single tokens ≤ ~20
|
||||||
|
characters** (`first_seen`, `when`, `started`, `finished`, `requested`,
|
||||||
|
`created`, `last_login`, `saved_at`, `changed_at`, `event_at`, `issue_date`,
|
||||||
|
`published`, `run.date`, `run.funnel` "310 → 200 → …", `age → decay`,
|
||||||
|
`#id`). **Remove it** from feed names/titles (`article.feed`, `candidate.feed`,
|
||||||
|
`miss.feed`, `row.feed_credits`, nearest-article `feed`), reasons
|
||||||
|
(`article.reason`, `row.reason`, `candidate.reason`), "admitted by" cells,
|
||||||
|
usernames / `requested_by` / `saved_by`, roles, sources, kinds, job names,
|
||||||
|
and from any cell that combines a badge with more text (stage + run date).
|
||||||
|
A `.badge` is already `whitespace-nowrap` on its own, so a badge-only cell
|
||||||
|
needs no class.
|
||||||
|
2. **`cell-wrap` on every free-form column**: titles, URLs, notes, messages,
|
||||||
|
"reason or comment", facet values, badge lists (interests), config diff
|
||||||
|
before/after, anything a user typed. Keep it where it already is.
|
||||||
|
3. **`num` on numbers only** (it already is; just make sure no text column
|
||||||
|
uses it because it wanted right alignment — `#id` cells may keep `num`).
|
||||||
|
4. **Long text that should not stretch a row**: if a column can hold hundreds of
|
||||||
|
characters (job `message`, rating-import `message`, `note`), keep
|
||||||
|
`cell-wrap` and wrap the text in `<div class="line-clamp-3" title="{{ … }}">`
|
||||||
|
**only** if the full text is reachable somewhere else on the page or via the
|
||||||
|
`title`; otherwise let it wrap. Never put `truncate`/`line-clamp-*` on a `td`.
|
||||||
|
5. **`dashboard/feeds.html`**: drop `table-fixed` (fixed layout is ignored once
|
||||||
|
the page-level table is `width:auto`). Keep the `w-*` hints on the `th`s
|
||||||
|
(they act as minimums under auto layout), keep the `line-clamp-*`/`truncate`
|
||||||
|
inner elements — they already sit on block children, which is the pattern —
|
||||||
|
and put `cell-wrap` on the Feed and Why cells if it is not there. The
|
||||||
|
actions cell keeps its forms; give it `cell-tight` only if the buttons wrap
|
||||||
|
badly, otherwise leave it unclassed.
|
||||||
|
6. **`dashboard/run.html` Feeds card** (`<table class="table-fixed">` inside
|
||||||
|
`.card`): this table is `w-full` inside a card, so `table-fixed` still works
|
||||||
|
there; leave it, but it is the only allowed `table-fixed`.
|
||||||
|
7. **Header cells**: remove nothing, but make sure no `th` has `whitespace-nowrap`
|
||||||
|
or `cell-tight`; long headers such as
|
||||||
|
"considered → eligible → triaged → assessed → shortlisted → selected" may wrap.
|
||||||
|
8. **Wrappers**: every page-level table (more than a handful of columns:
|
||||||
|
runs, candidates, articles, ratings current/events, interests, feeds, users,
|
||||||
|
jobs, stats runs/costs, settings history, profile versions, near misses,
|
||||||
|
funnel) must sit in a `<div class="scroll-x …">` that is a **direct child**
|
||||||
|
of `<section class="dashboard …">` — check that no such wrapper is nested in
|
||||||
|
a stray `<div>`; tables inside `.card`/`.cards`/`details` stay where they are.
|
||||||
|
Keep `tall` where it is today.
|
||||||
|
|
||||||
|
Do not change the columns themselves, their order, the text, or any
|
||||||
|
`data-*`/`id`/`name`/`action`/`aria-*` attribute. Do not touch reader/public
|
||||||
|
templates (`issue_*.html`, `article.html`, `world.html`, `behind.html`, `_toc.html`).
|
||||||
|
|
||||||
|
Verify: `cargo test --lib web::` (router tests assert on text and some class
|
||||||
|
names; if one breaks only because a class moved, update the test and say so),
|
||||||
|
then `cargo fmt`/`clippy`. List in the handoff, per template, which cells lost
|
||||||
|
`cell-tight`, which gained `cell-wrap`, and any clamp you added.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Dashboard tables CSS handoff
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `src/web/tailwind.css`
|
||||||
|
- Disabled Tailwind v4 automatic repository scanning with
|
||||||
|
`@import "tailwindcss" source(none);`; retained the explicit template and
|
||||||
|
`app.js` sources.
|
||||||
|
- Replaced the padded/max-width dashboard container with the specified named
|
||||||
|
`content`/`wide` grid and 1rem/1.5rem gutter variable.
|
||||||
|
- Let direct-child `.scroll-x` wrappers occupy the wide track and made their
|
||||||
|
tables shrink-to-fit between the content-column floor and wrapper ceiling.
|
||||||
|
- Removed the global `.scroll-x > table { min-width:max-content; }` rule.
|
||||||
|
- Kept `.num` alignment/tabular figures on headers and cells, while limiting
|
||||||
|
nowrap to `td.num`.
|
||||||
|
- Raised `.cell-wrap`'s maximum width from 24rem to 32rem.
|
||||||
|
- Added a pointer from the table rules to `docs/dashboard-tables.md`.
|
||||||
|
- `src/web/static/app.css`
|
||||||
|
- Rebuilt from the Tailwind source and checked for reproducibility.
|
||||||
|
|
||||||
|
No templates, JavaScript, reader table rules, preview rules, or rating-cell
|
||||||
|
rules changed.
|
||||||
|
|
||||||
|
## Generated CSS diff audit
|
||||||
|
|
||||||
|
Compared committed and rebuilt CSS one closing brace per line with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
diff -U0 \
|
||||||
|
<(git show HEAD:src/web/static/app.css | tr '}' '\n') \
|
||||||
|
<(tr '}' '\n' < src/web/static/app.css)
|
||||||
|
```
|
||||||
|
|
||||||
|
The 177-line diff contained 111 removed and 12 added content lines (excluding
|
||||||
|
the two diff file headers). The component-rule changes were exactly the table
|
||||||
|
framework changes above: remove `.scroll-x > table`, replace `.dashboard`, add
|
||||||
|
the three direct-child dashboard selectors, split `td.num` nowrap from `.num`,
|
||||||
|
and change `.cell-wrap` from 24rem to 32rem. The remainder was removal of
|
||||||
|
utilities, unused theme symbols, and supporting custom properties that had
|
||||||
|
previously been emitted by repository-wide automatic source discovery. There
|
||||||
|
were no unrelated added selectors.
|
||||||
|
|
||||||
|
The rebuilt file shrank from 77,721 to 70,697 bytes. The documentation-only
|
||||||
|
utility selectors `.line-clamp-3`, `.max-w-[32rem]`, `.min-w-[10rem]`, and
|
||||||
|
`.pb-6` are absent. The semantic `.cell-wrap` component still contains
|
||||||
|
`min-width:10rem` and `max-width:32rem`, and `.dashboard` still contains its
|
||||||
|
requested `padding-bottom` declaration.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run css` — passed.
|
||||||
|
- `npm run css:check` — passed with no diff.
|
||||||
|
- `cargo build` — passed after the final CSS build.
|
||||||
|
- `cargo test --lib web::` — 135 passed; the two failures were
|
||||||
|
`web::dashboard::feeds::tests::a_duplicate_subscription_still_decides_the_row`
|
||||||
|
and `web::dashboard::feeds::tests::add_subscribes_and_records_the_miniflux_feed_id`.
|
||||||
|
Both failed at the test bind setup with the documented
|
||||||
|
`PermissionDenied` / `Operation not permitted` sandbox error.
|
||||||
|
- `cargo fmt` — passed without changes.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
|
||||||
|
An extra full `cargo test` required by the shared brief was attempted, but the
|
||||||
|
filesystem ran out of space while creating another test archive before tests
|
||||||
|
started. I removed only this package's generated artifacts with
|
||||||
|
`cargo clean -p daily-epub` (2.0 GiB), then reran the complete user-requested
|
||||||
|
command sequence above successfully. The full suite remains unverified; the
|
||||||
|
focused web suite is verified subject only to the two allowed bind failures.
|
||||||
|
|
||||||
|
## Deviations and open items
|
||||||
|
|
||||||
|
There are no implementation deviations. I did not run the browser measurement
|
||||||
|
script because the sandbox cannot bind a preview server, as independently
|
||||||
|
confirmed by the focused test failures. Visual viewport measurement remains for
|
||||||
|
the post-merge environment described in the shared brief.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Dashboard tables template handoff
|
||||||
|
|
||||||
|
Implemented `02-templates.md` against `00-shared.md` and
|
||||||
|
`docs/dashboard-tables.md`. No columns, content, hooks, or attributes were
|
||||||
|
changed. The existing page-level `.scroll-x` wrappers already had the required
|
||||||
|
direct-child topology, so none moved.
|
||||||
|
|
||||||
|
## Cell audit by template
|
||||||
|
|
||||||
|
| Template | Cells that lost `cell-tight` | Cells that gained `cell-wrap` | Clamp added |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `_candidate_row.html` | Feed, stage, reason, admitted by, flags | Flags (badge list) | None |
|
||||||
|
| `_signals_table.html` | None | None | None |
|
||||||
|
| `dashboard/article.html` | Run history: stage, reason, admitted by; nearest articles: feed, rating; rating events: label, kind, source, user | Top-interests interest name | Rating-event note: inner `line-clamp-3` with the full note in `title` |
|
||||||
|
| `dashboard/articles.html` | Feed, last stage (badge plus run date), reason, rating | None | None |
|
||||||
|
| `dashboard/feeds.html` | None | None (Feed and Why already had it) | None (existing Feed/Why inner clamps retained) |
|
||||||
|
| `dashboard/interests.html` | Category | None | None |
|
||||||
|
| `dashboard/jobs.html` | Job name, requested by, status | None (message already had it) | Message: inner `line-clamp-3` with the full message in `title` |
|
||||||
|
| `dashboard/profile.html` | Saved by, restore action | Preview | None |
|
||||||
|
| `dashboard/ratings.html` | Imports: verdict, status; events: verdict, source, by; current: when/by, feed credit, used last run | Current feed credit | Import message, event note, and current note: inner `line-clamp-3` with full text in `title` |
|
||||||
|
| `dashboard/run.html` | Funnel stage; near misses: feed, stage | None | None |
|
||||||
|
| `dashboard/runs.html` | Status (badge plus dry-run text) | None | None |
|
||||||
|
| `dashboard/settings_history.html` | Who | None | None |
|
||||||
|
| `dashboard/stats.html` | Runs status | None | None |
|
||||||
|
| `dashboard/users.html` | None | None | None |
|
||||||
|
|
||||||
|
`dashboard/feeds.html` also lost page-level `table-fixed`; its existing header
|
||||||
|
width hints and inner Feed/Why clamps remain. The card-local `table-fixed` feeds
|
||||||
|
table in `dashboard/run.html` remains as specified.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None. Templates whose cells were already compliant were left unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo test --lib web::`: 135 passed; two Miniflux mock-server tests failed
|
||||||
|
only because sandbox `bind()` returned `PermissionDenied` at
|
||||||
|
`src/web/dashboard/feeds.rs:497`, as permitted by the brief.
|
||||||
|
- `cargo fmt`: passed.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: passed.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
|
||||||
|
The first clippy attempt exhausted the filesystem while writing generated Rust
|
||||||
|
metadata. Removing the disposable `target/debug/incremental` cache freed enough
|
||||||
|
space; the exact clippy command then completed successfully.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// usage: node measure.mjs <port> <outdir> [widths]
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import fs from "node:fs";
|
||||||
|
const [port, outdir, widthsArg] = process.argv.slice(2);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const widths = (widthsArg || "390,768,1280,1920,2560").split(",").map(Number);
|
||||||
|
const pages = ["/dashboard","/dashboard/runs","/dashboard/runs/1","/dashboard/runs/2","/dashboard/articles","/dashboard/articles/1","/dashboard/ratings","/dashboard/ratings?tab=events","/dashboard/interests","/dashboard/feeds","/dashboard/feeds?status=added","/dashboard/profile","/dashboard/stats","/dashboard/jobs","/dashboard/settings","/dashboard/settings/history","/dashboard/users","/","/issues","/account"];
|
||||||
|
fs.mkdirSync(outdir, { recursive: true });
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${base}/login`);
|
||||||
|
await page.fill('input[name="username"]', "admin");
|
||||||
|
await page.fill('input[name="password"]', "adminpassword123");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
const report = [];
|
||||||
|
for (const w of widths) {
|
||||||
|
await page.setViewportSize({ width: w, height: 1000 });
|
||||||
|
for (const p of pages) {
|
||||||
|
const resp = await page.goto(`${base}${p}`, { waitUntil: "networkidle" });
|
||||||
|
const status = resp?.status();
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const de = document.documentElement;
|
||||||
|
const container = document.querySelector(".dashboard, main > section, main > div, main");
|
||||||
|
const tables = [...document.querySelectorAll("table")].map((t) => {
|
||||||
|
const host = t.closest(".scroll-x") || t.parentElement;
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
return { cls: t.className, ths: t.querySelectorAll("thead th").length, rows: t.querySelectorAll("tbody tr").length, tableW: Math.round(r.width), hostW: Math.round(host.getBoundingClientRect().width), hostScroll: host.scrollWidth, hostClient: host.clientWidth, overflow: host.scrollWidth > host.clientWidth + 1 };
|
||||||
|
});
|
||||||
|
const culprits = [...document.querySelectorAll("body *")].filter((el) => el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX === "visible" && !el.closest("table")).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0,2).join(".")} ${el.scrollWidth}>${el.clientWidth}`);
|
||||||
|
return { pageOverflow: de.scrollWidth > de.clientWidth, docW: de.clientWidth, containerW: container ? Math.round(container.getBoundingClientRect().width) : null, tables, culprits };
|
||||||
|
});
|
||||||
|
const name = p.replace(/[\/?=]/g, "_").replace(/^_/, "") || "root";
|
||||||
|
if (w === 1280 || w === 2560 || w === 390) await page.screenshot({ path: `${outdir}/${w}-${name}.png`, fullPage: true });
|
||||||
|
report.push({ w, p, status, ...info });
|
||||||
|
const bad = info.tables.filter((t) => t.overflow);
|
||||||
|
console.log(`${w} ${p} status=${status} page=${info.pageOverflow ? "OVERFLOW" : "ok"} container=${info.containerW} tables=${info.tables.length} scrolling=${bad.length}${bad.length ? " [" + bad.map((t) => `${t.cls || "-"}:${t.hostScroll}>${t.hostClient}`).join(", ") + "]" : ""}${info.culprits.length ? " culprits=" + info.culprits.join(" | ") : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${outdir}/report.json`, JSON.stringify(report, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times", "Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settLine truncated
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{paddiLine truncated
|
||||||
+29
-5
@@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss" source(none);
|
||||||
@source "../web/templates";
|
@source "../web/templates";
|
||||||
@source "./static/app.js";
|
@source "./static/app.js";
|
||||||
|
|
||||||
@@ -159,8 +159,8 @@
|
|||||||
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
||||||
.kv dt { @apply text-sm text-muted; }
|
.kv dt { @apply text-sm text-muted; }
|
||||||
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
||||||
|
/* Dashboard table layout contract: docs/dashboard-tables.md. */
|
||||||
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
||||||
.scroll-x > table { min-width:max-content; }
|
|
||||||
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
||||||
|
|
||||||
.reader-page { @apply min-w-0; overflow-wrap:anywhere; }
|
.reader-page { @apply min-w-0; overflow-wrap:anywhere; }
|
||||||
@@ -219,7 +219,30 @@
|
|||||||
ul.error, ul.notice { @apply list-disc pl-8; }
|
ul.error, ul.notice { @apply list-disc pl-8; }
|
||||||
|
|
||||||
/* ---------- dashboard shell ---------- */
|
/* ---------- dashboard shell ---------- */
|
||||||
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 font-sans text-base leading-normal sm:px-6; }
|
.dashboard {
|
||||||
|
--gutter:1rem;
|
||||||
|
@apply my-8 grid pb-6 font-sans text-base leading-normal;
|
||||||
|
grid-template-columns:
|
||||||
|
[full-start] var(--gutter)
|
||||||
|
[wide-start] 1fr
|
||||||
|
[content-start] minmax(0, calc(80rem - 2 * var(--gutter)))
|
||||||
|
[content-end] 1fr
|
||||||
|
[wide-end] var(--gutter)
|
||||||
|
[full-end];
|
||||||
|
}
|
||||||
|
@media (width >= 40rem) { .dashboard { --gutter:1.5rem; } }
|
||||||
|
/* Grid items never collapse margins, so a child's bottom margin (`.table-filter`, `.rating`) would add to
|
||||||
|
the next child's `mt-4`; the rhythm comes from top margins only. `!important` beats those utilities. */
|
||||||
|
.dashboard > * { grid-column:content; min-width:0; margin-bottom:0 !important; }
|
||||||
|
/* The wrapper shrinks to its table (a centred grid item is sized fit-content), so the
|
||||||
|
flanks beside a narrow table stay page, not scroll container: wheeling there scrolls the page. */
|
||||||
|
.dashboard > .scroll-x {
|
||||||
|
grid-column:wide;
|
||||||
|
justify-self:center;
|
||||||
|
min-width:min(100%, calc(80rem - 2 * var(--gutter)));
|
||||||
|
max-width:100%;
|
||||||
|
}
|
||||||
|
.dashboard > .scroll-x > table { width:auto; min-width:100%; }
|
||||||
.dashboard > * + * { @apply mt-4; }
|
.dashboard > * + * { @apply mt-4; }
|
||||||
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
||||||
.dashboard h2 { @apply text-lg font-semibold; }
|
.dashboard h2 { @apply text-lg font-semibold; }
|
||||||
@@ -254,8 +277,9 @@
|
|||||||
|
|
||||||
.muted, .meta { @apply text-muted; }
|
.muted, .meta { @apply text-muted; }
|
||||||
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
||||||
.num { @apply whitespace-nowrap text-right tabular-nums; }
|
.num { @apply text-right tabular-nums; }
|
||||||
.cell-wrap { @apply min-w-[10rem] max-w-[24rem] whitespace-normal; overflow-wrap:anywhere; }
|
td.num { @apply whitespace-nowrap; }
|
||||||
|
.cell-wrap { @apply min-w-[10rem] max-w-[32rem] whitespace-normal; overflow-wrap:anywhere; }
|
||||||
.cell-tight { @apply whitespace-nowrap; }
|
.cell-tight { @apply whitespace-nowrap; }
|
||||||
|
|
||||||
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
<td class="cell-tight text-muted">{{ candidate.feed }}</td>
|
<td class="text-muted">{{ candidate.feed }}</td>
|
||||||
<td class="num">{{ candidate.words }}</td>
|
<td class="num">{{ candidate.words }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
<td><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||||
<td class="cell-tight">{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
<td>{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
<td class="text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
<td class="num">{{ candidate.utility }}</td>
|
<td class="num">{{ candidate.utility }}</td>
|
||||||
<td class="num">{{ candidate.rank }}</td>
|
<td class="num">{{ candidate.rank }}</td>
|
||||||
<td class="num">{{ candidate.cluster }}</td>
|
<td class="num">{{ candidate.cluster }}</td>
|
||||||
<td class="num">{{ candidate.triage }}</td>
|
<td class="num">{{ candidate.triage }}</td>
|
||||||
<td class="num">{{ candidate.quality }}</td>
|
<td class="num">{{ candidate.quality }}</td>
|
||||||
<td class="num">{{ candidate.fit }}</td>
|
<td class="num">{{ candidate.fit }}</td>
|
||||||
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
<td>{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
<tbody>{% for row in history %}<tr>
|
<tbody>{% for row in history %}<tr>
|
||||||
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
<td class="cell-tight text-muted">{{ row.date }}</td>
|
<td class="cell-tight text-muted">{{ row.date }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
<td><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
<td class="text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
<td class="text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
<td class="num">{{ row.utility }}</td>
|
<td class="num">{{ row.utility }}</td>
|
||||||
<td class="num">{{ row.rank }}</td>
|
<td class="num">{{ row.rank }}</td>
|
||||||
<td class="num">{{ row.cluster }}</td>
|
<td class="num">{{ row.cluster }}</td>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<h2>Neighbours and interests</h2>
|
<h2>Neighbours and interests</h2>
|
||||||
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
||||||
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td class="cell-wrap"><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
<tbody>{% for article in articles %}<tr>
|
<tbody>{% for article in articles %}<tr>
|
||||||
<td class="num">{{ article.cosine }}</td>
|
<td class="num">{{ article.cosine }}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ article.id }}">{{ article.title }}</a></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ article.id }}">{{ article.title }}</a></td>
|
||||||
<td class="cell-tight">{{ article.feed }}</td>
|
<td>{{ article.feed }}</td>
|
||||||
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</td>
|
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No embedding stored — run features-backfill.</p>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No embedding stored — run features-backfill.</p>{% endif %}
|
||||||
|
|
||||||
<h2>Embedding</h2>
|
<h2>Embedding</h2>
|
||||||
@@ -80,12 +80,12 @@
|
|||||||
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
||||||
<tbody>{% for event in events %}<tr>
|
<tbody>{% for event in events %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ event.event_at }}</td>
|
<td class="cell-tight text-muted">{{ event.event_at }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
<td><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
||||||
<td class="num">{{ event.value }}</td>
|
<td class="num">{{ event.value }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.kind }}</td>
|
<td class="text-muted">{{ event.kind }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
<td class="text-muted">{{ event.source }}</td>
|
||||||
<td class="cell-tight">{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
<td>{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}<div class="line-clamp-3" title="{{ note }}">{{ note }}</div>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -18,21 +18,21 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter>
|
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="articles" data-filter>
|
||||||
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
||||||
<tbody>{% for article in articles %}<tr>
|
<tbody>{% for article in articles %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||||
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
<td>{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
||||||
<td class="cell-wrap">{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
|
<td>{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
|
||||||
<td class="num">{{ article.words }}</td>
|
<td class="num">{{ article.words }}</td>
|
||||||
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
<td>{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
<td class="text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
||||||
<td class="num">{{ article.utility }}</td>
|
<td class="num">{{ article.utility }}</td>
|
||||||
<td class="num">{{ article.triage }}</td>
|
<td class="num">{{ article.triage }}</td>
|
||||||
<td class="num">{{ article.quality }}</td>
|
<td class="num">{{ article.quality }}</td>
|
||||||
<td class="num">{{ article.fit }}</td>
|
<td class="num">{{ article.fit }}</td>
|
||||||
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||||
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<nav class="tabs" aria-label="Candidate status"><a href="/dashboard/feeds"{% if status == "candidate" %} class="active" aria-current="page"{% endif %}>Candidates ({{ candidate_count }})</a> <a href="/dashboard/feeds?status=added"{% if status == "added" %} class="active" aria-current="page"{% endif %}>Added ({{ added_count }})</a> <a href="/dashboard/feeds?status=dismissed"{% if status == "dismissed" %} class="active" aria-current="page"{% endif %}>Dismissed ({{ dismissed_count }})</a></nav>
|
<nav class="tabs" aria-label="Candidate status"><a href="/dashboard/feeds"{% if status == "candidate" %} class="active" aria-current="page"{% endif %}>Candidates ({{ candidate_count }})</a> <a href="/dashboard/feeds?status=added"{% if status == "added" %} class="active" aria-current="page"{% endif %}>Added ({{ added_count }})</a> <a href="/dashboard/feeds?status=dismissed"{% if status == "dismissed" %} class="active" aria-current="page"{% endif %}>Dismissed ({{ dismissed_count }})</a></nav>
|
||||||
{% if let Some(error) = categories_error %}<p class="notice">{{ error }}</p>{% endif %}
|
{% if let Some(error) = categories_error %}<p class="notice">{{ error }}</p>{% endif %}
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
<div class="scroll-x"><table class="table-fixed">
|
<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num w-14">Score</th><th>Feed</th><th>Why</th><th class="num w-20">Articles</th><th class="w-20">Seen</th><th class="w-60 lg:w-84"><span class="sr-only">Actions</span></th></tr></thead>
|
<thead><tr><th class="num w-14">Score</th><th>Feed</th><th>Why</th><th class="num w-20">Articles</th><th class="w-20">Seen</th><th class="w-60 lg:w-84"><span class="sr-only">Actions</span></th></tr></thead>
|
||||||
<tbody>{% for row in rows %}<tr>
|
<tbody>{% for row in rows %}<tr>
|
||||||
<td class="num tabular-nums">{{ row.score }}</td>
|
<td class="num tabular-nums">{{ row.score }}</td>
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
|
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
|
||||||
<div class="scroll-x tall"><table data-filter>
|
<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
|
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
|
||||||
<tbody>{% for row in rows %}<tr>
|
<tbody>{% for row in rows %}<tr>
|
||||||
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
|
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
|
||||||
<td class="cell-tight">{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
|
<td>{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
|
||||||
<td class="num tabular-nums">{{ row.weight }}</td>
|
<td class="num tabular-nums">{{ row.weight }}</td>
|
||||||
<td class="num tabular-nums">{{ row.up }}</td>
|
<td class="num tabular-nums">{{ row.up }}</td>
|
||||||
<td class="num tabular-nums">{{ row.down }}</td>
|
<td class="num tabular-nums">{{ row.down }}</td>
|
||||||
|
|||||||
@@ -17,18 +17,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>{% endfor %}</div>
|
</section>{% endfor %}</div>
|
||||||
<h2>History</h2>
|
<h2>History</h2>
|
||||||
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
||||||
<tbody>{% for job in jobs %}<tr>
|
<tbody>{% for job in jobs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||||
<td class="cell-tight font-mono text-xs">{{ job.name }}</td>
|
<td class="font-mono text-xs">{{ job.name }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.requested_by }}</td>
|
<td class="text-muted">{{ job.requested_by }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.started }}</td>
|
<td class="cell-tight text-muted">{{ job.started }}</td>
|
||||||
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
||||||
<td class="num">{{ job.duration }}</td>
|
<td class="num">{{ job.duration }}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
<td><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
||||||
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
|
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}<div class="line-clamp-3" title="{{ message }}">{{ message }}</div>{% endif %}</td>
|
||||||
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
<tbody>{% for version in versions %}<tr>
|
<tbody>{% for version in versions %}<tr>
|
||||||
<td class="num">#{{ version.id }}</td>
|
<td class="num">#{{ version.id }}</td>
|
||||||
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
||||||
<td class="cell-tight">{{ version.saved_by }}</td>
|
<td>{{ version.saved_by }}</td>
|
||||||
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
||||||
<td><pre class="preview">{{ version.preview }}</pre></td>
|
<td class="cell-wrap"><pre class="preview">{{ version.preview }}</pre></td>
|
||||||
<td class="cell-tight"><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
<td><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
||||||
</tr>{% endfor %}</tbody></table></div>
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<tbody>{% for row in imports %}<tr>
|
<tbody>{% for row in imports %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ row.requested }}</td>
|
<td class="cell-tight text-muted">{{ row.requested }}</td>
|
||||||
<td class="cell-wrap"><a href="{{ row.url }}" rel="noopener" target="_blank">{{ row.url }}</a></td>
|
<td class="cell-wrap"><a href="{{ row.url }}" rel="noopener" target="_blank">{{ row.url }}</a></td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.label }}">{{ row.verdict }}</span></td>
|
<td><span class="badge {{ row.label }}">{{ row.verdict }}</span></td>
|
||||||
<td class="cell-tight"><span class="badge {{ row.status }}">{{ row.status }}</span></td>
|
<td><span class="badge {{ row.status }}">{{ row.status }}</span></td>
|
||||||
<td class="cell-wrap text-muted">{{ row.message }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ row.message }}">{{ row.message }}</div></td>
|
||||||
<td class="num">{% if let Some(article_id) = row.article_id %}<a href="/dashboard/articles/{{ article_id }}">{{ article_id }}</a>{% endif %}</td>
|
<td class="num">{% if let Some(article_id) = row.article_id %}<a href="/dashboard/articles/{{ article_id }}">{{ article_id }}</a>{% endif %}</td>
|
||||||
</tr>{% endfor %}
|
</tr>{% endfor %}
|
||||||
{% if imports.is_empty() %}<tr><td colspan="6" class="text-muted">No rating imports yet.</td></tr>{% endif %}</tbody>
|
{% if imports.is_empty() %}<tr><td colspan="6" class="text-muted">No rating imports yet.</td></tr>{% endif %}</tbody>
|
||||||
@@ -55,19 +55,19 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
||||||
<div class="scroll-x tall"><table>
|
<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
||||||
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
||||||
<td class="cell-tight"><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
<td><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
||||||
<td class="num">{{ event.value }}</td>
|
<td class="num">{{ event.value }}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
||||||
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.when }}</td>
|
<td class="cell-tight text-muted">{{ event.when }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
<td class="text-muted">{{ event.source }}</td>
|
||||||
<td class="cell-tight text-muted">{{ event.username }}</td>
|
<td class="text-muted">{{ event.username }}</td>
|
||||||
<td class="cell-wrap text-muted">{{ event.note }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ event.note }}">{{ event.note }}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
||||||
@@ -83,20 +83,20 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
||||||
<div class="scroll-x tall"><table class="contributions">
|
<div class="scroll-x"><table class="contributions">
|
||||||
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in current %}<tr>
|
{% for row in current %}<tr>
|
||||||
<td class="rating-cell min-w-[13rem]">{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
<td class="rating-cell min-w-[13rem]">{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
||||||
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
||||||
<td class="cell-wrap text-muted">{{ row.note }}</td>
|
<td class="cell-wrap text-muted"><div class="line-clamp-3" title="{{ row.note }}">{{ row.note }}</div></td>
|
||||||
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
||||||
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
<td class="cell-wrap text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||||
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
<td class="cell-tight tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
<td class="tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="scroll-x"><table class="funnel-table">
|
<div class="scroll-x"><table class="funnel-table">
|
||||||
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
||||||
<tbody>{% for stage in funnel %}<tr>
|
<tbody>{% for stage in funnel %}<tr>
|
||||||
<td class="cell-tight align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
<td class="align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
||||||
<td class="funnel-cell"><div class="funnel"><svg class="h-3" width="{{ stage.percent }}%" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
<td class="funnel-cell"><div class="funnel"><svg class="h-3" width="{{ stage.percent }}%" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
||||||
<td class="num align-middle">{{ stage.reached }}</td>
|
<td class="num align-middle">{{ stage.reached }}</td>
|
||||||
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<h2>Near misses</h2>
|
<h2>Near misses</h2>
|
||||||
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||||
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
||||||
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="cell-tight text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td class="cell-tight"><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<h2>Candidates</h2>
|
<h2>Candidates</h2>
|
||||||
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="candidates" data-filter>
|
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="candidates" data-filter>
|
||||||
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th></tr></thead>
|
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th></tr></thead>
|
||||||
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="13" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="13" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
||||||
</form>
|
</form>
|
||||||
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
|
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
|
||||||
<tbody>{% for run in runs %}<tr>
|
<tbody>{% for run in runs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||||
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||||
<td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
<td><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{{ run.started }}</td>
|
<td class="cell-tight text-muted">{{ run.started }}</td>
|
||||||
<td class="num text-muted">{{ run.duration }}</td>
|
<td class="num text-muted">{{ run.duration }}</td>
|
||||||
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<h1>Settings history</h1>
|
<h1>Settings history</h1>
|
||||||
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
||||||
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
||||||
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="history" data-filter>
|
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table class="history" data-filter>
|
||||||
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
||||||
<tbody>{% for change in changes %}<tr>
|
<tbody>{% for change in changes %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||||
<td class="cell-tight">{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
<td>{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
||||||
<td><code>{{ change.key }}</code></td>
|
<td><code>{{ change.key }}</code></td>
|
||||||
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
||||||
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
||||||
|
|||||||
@@ -27,14 +27,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Spend by day</h2>
|
<h2>Spend by day</h2>
|
||||||
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
||||||
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<h2>Runs in the window</h2>
|
<h2>Runs in the window</h2>
|
||||||
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
||||||
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" autocomplete="username" maxlength="32" required pattern="[A-Za-z0-9._-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" autocomplete="username" maxlength="32" required pattern="[A-Za-z0-9._-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x"><table data-filter>
|
||||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||||
<tbody>{% for user in users %}<tr>
|
<tbody>{% for user in users %}<tr>
|
||||||
<td class="font-medium text-ink">{{ user.username }}</td>
|
<td class="font-medium text-ink">{{ user.username }}</td>
|
||||||
|
|||||||
Reference in new issue
Block a user