Compare commits

..

6 Commits

Author SHA1 Message Date
yaroslav k
6b792734b6 note kept dependencies in MODERNIZE.md and ROADMAP
itertools, thiserror, anyhow, pretty_env_logger, dotenvy stay for
certain; the rest of the unused-dependency list is under consideration.
2026-08-04 14:53:52 +03:00
yaroslav k
af60019fa2 add MODERNIZE.md: dependency and idiom cleanup list
Verified dependency reductions (13 unused or replaceable crates) and
idiomatic cleanups for the current codebase. Not applied yet; linked from
ROADMAP phase 0. Toolchain stays on nightly.
2026-08-04 09:39:37 +03:00
yaroslav k
ec4e771792 add ARCHITECTURE.md: concise overview of the codebase
State model (Store<Dashboard> via context), bundled item data, SSR/hydrate
pipeline, component tree, conventions, and current gaps.
2026-08-04 09:21:25 +03:00
yaroslav k
257f254b13 document every function, trait and struct in src/
Doc comments on all modules, components, entities, helpers and type
aliases; larger comments on the important entities (Dashboard, PlayerData,
Context, App, Numput, item data). No behavior changes.
2026-08-04 09:21:25 +03:00
yaroslav k
da405758ce add ROADMAP.md: phased plan to MVP
Covers full campaign dashboard scope (character, inventory, quests,
notes, contacts, wiki) with localStorage + export/import persistence.
Phases 0-8 with acceptance criteria and effort estimates.
2026-08-04 09:13:51 +03:00
yaroslav k
8eee42c6dd bump leptos to 0.8.20
- leptos 0.8.2 -> 0.8.20, meta/router/actix to latest 0.8.x
- reactive_stores 0.2.5 -> 0.4.3 (matches reactive_graph used by leptos 0.8.20)
- wasm-bindgen =0.2.101 -> =0.2.126, js-sys =0.3.78 -> =0.3.103

Fixes the build with the current nightly (tachys 0.2.18 no longer
needs the re-gated min_adt_const_params feature).
2026-08-04 06:40:38 +03:00
27 changed files with 707 additions and 7 deletions

165
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,165 @@
# ARCHITECTURE
This document describes how `aex` works today. It is a snapshot, not a design
spec: several pieces are stubs or unwired (see "Current gaps" at the end and
the phased plan in [ROADMAP.md](./ROADMAP.md)).
## What it is
A D&D 5e character sheet and campaign dashboard for one party's play-by-text
game. The UI is in Russian; code and comments are in English. The app tracks
the campaign (name, in-game date, session number), one player character
(identity, class, HP, money, spell slots, inventory, hit dice, death saves),
a party-shared stash, and — in the data model only, no UI yet — quests,
notes, and contacts.
## Stack
- Rust, edition 2024, nightly toolchain (`rust-toolchain.toml`, channel
pinned to `nightly` without a date)
- Leptos 0.8.20 (SSR + hydration), reactive_stores 0.4.3 for state
- leptos_actix 0.8.7 + actix-web 4 for the server
- nucleo-matcher for fuzzy item search, num-format for number display,
chrono for dates, lazy_static for one-time data loading
- SCSS sources in `style/` (compiled by cargo-leptos)
- Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total)
Features: `ssr` (server binary), `hydrate` (WASM), `csr` (optional pure-client
mode for `trunk`). `Cargo.lock` is gitignored.
## Directory layout
```
src/
main.rs SSR entry point: actix-web server, favicon
lib.rs crate root, module declarations, WASM hydrate()
app.rs App component: context, shell, routes
prelude.rs crate-wide re-exports and the Context alias
state/ App state (one Store<Dashboard> in context)
entities/ Domain model (Store-derived structs) + mock data
statics/ Bundled item data: ITEMS_REFS, ITEMS_NAMES
utils/ read_items, adjust_checked, BasisPoints trait
components/
header.rs Campaign header (name, date, session, Save/Load/Clear)
nav.rs Route links
sidebar.rs Character sidebar, composes the seven panels below
sidebar/*.rs image, names, about, hit_points, hit_dice,
death_saving_throws, spell_slots
inventory.rs One inventory section (rows + money)
item.rs One inventory row
numput.rs Numeric input components (Numput, NumputChange)
mod.rs Pages (Character, Inventories, Wiki) + item search
```
## State model
The whole dashboard is one reactive tree:
- `entities::Dashboard` is the root struct: campaign fields, `player:
PlayerData`, `common: CommonData`, plus quest/note/contact books and
settings.
- Every entity struct derives `reactive_stores::Store`, so every nested
field is independently reactive (copy-on-write paths).
- `App` creates `Store::new(Dashboard::mock())` and provides it as Leptos
context. Components read it with `expect_context::<Context>()` and grab
field handles, e.g. `let hp = state.player().hp();`.
- Writes go through `field.update(|v| ...)`, `field.set(...)`, or
`field.write()`. All arithmetic is saturating (`saturating_add/sub`,
`checked_add_signed`) so values never underflow.
The state is mock data only — `Dashboard::mock()` runs on every load and
nothing is persisted. The header's Save/Load/Clear buttons do nothing yet
(ROADMAP phase 1).
## Data layer (items)
- `statics::ITEMS_REFS` is a `lazy_static` map of name → `BookItem`, built
once by `utils::read_items` over the bundled JSON files (`items.json`,
`base.json`, `foundry.json` loaded twice — a known duplication — and
`magic.json`), roughly 5k items.
- `BookItem` keeps `name`, `weight`, `rarity`, and `rest`, which holds all
other JSON fields verbatim for future detail views.
- Duplicate names merge: the later entry fills a missing weight and appends
its `rest` fields.
- `ITEMS_NAMES` is the flat name list fed to the fuzzy matcher.
Weights are stored as `u32` basis points (100 = 1.0) in inventory items and
formatted with the `utils::BasisPoints` trait; book items use `f64` from the
JSON as-is.
## Rendering pipeline
1. `main.rs` (feature `ssr`) starts an actix-web server, generates the route
list from `App`, and renders each route server-side through
`leptos_routes`. It serves the WASM/CSS from `/pkg` and static assets
from `/assets`.
2. The WASM bundle (`hydrate` feature) calls `hydrate()` in `lib.rs`, which
mounts `App` with `hydrate_body` over the server-rendered DOM.
3. Routing: `/` → Character page, `/inv` → Inventories, `/wiki` → Wiki,
anything else → `NotFound` (sets HTTP 404 during SSR).
## Component tree
```
App
├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead)
├─ Sidebar
│ ├─ Image portrait
│ ├─ Names first / alias «» / last
│ ├─ About class, level ±, XP
│ ├─ HitPoints current/max with bar, temp HP
│ ├─ HitDice die-kind select, per-level clickable dice
│ ├─ DeathSavingThrows fail/success pips + reset
│ └─ SpellSlots 9 levels of clickable pips
├─ Nav links to the three routes
└─ Routes
├─ Character stub text
├─ Inventories two Inventory sections (Personal/Common)
│ └─ Inventory balance (5 × Numput), rows (ForEnumerate)
│ └─ Item name, quantity ±, weight ±, more/swap/remove
└─ Wiki stub text
```
The inventories page reads `Context`, splits it into `Field<Inventory>` and
`Field<Balance>` handles, and passes them down. Item rows get move/remove
closures by index; "move" transfers a row between the personal and common
inventories, "remove" deletes it. The "…" (details) button and the search
"+" button are unwired.
`Numput` is the shared numeric input: `+n`/`-n` adjust relative to the
current value, a plain number sets absolutely, invalid input is reverted,
and the value renders with thousands separators. `NumputChange` is the same
pattern with caller-supplied display and parse functions (used for weights
in basis points).
Item search (`ItemSearchField`) re-runs a nucleo fuzzy match on every
keystroke over `ITEMS_NAMES` and renders `ItemSearchCard`s. It is not
currently mounted on any page.
## Conventions
- UI strings are Russian and hardcoded in the views; ids like `id="hp"`
exist for e2e tests.
- Collections come from `std::collections` via the prelude (`HashMap`,
`HashSet`).
- `For` lists are keyed: inventories use `#[store(key)]` on `row.id`,
sidebar pips key by index.
- Logging goes through the `log` macros re-exported in the prelude
(`info!`, `warn!`, ...).
## Current gaps
- No persistence; state is recreated from `mock()` each load
- `Character` and `Wiki` pages render placeholders
- Quests, notes, contacts: models and mock data only
- Prepared spells list is empty; `Spell` is a name-only stub; attunements
have no UI; `Settings` is empty
- Save/Load/Clear buttons and the item "+"/"…" buttons are dead
- `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM
and parsed on startup
- Several unused dependencies (`leptos-use`, `dotenvy`,
`pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12
compiler warnings
- `Cargo.lock` is gitignored, so builds are not reproducible
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.

View File

@ -11,11 +11,11 @@ actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] } actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
http = { version = "1.3.1", optional = true } http = { version = "1.3.1", optional = true }
leptos = { version = "0.8.2", features = ["nightly"] } leptos = { version = "0.8.20", features = ["nightly"] }
leptos_meta = { version = "0.8.2" } leptos_meta = { version = "0.8.6" }
leptos_actix = { version = "0.8.2", optional = true } leptos_actix = { version = "0.8.7", optional = true }
leptos_router = { version = "0.8.2", features = ["nightly"] } leptos_router = { version = "0.8.15", features = ["nightly"] }
wasm-bindgen = "=0.2.101" wasm-bindgen = "=0.2.126"
anyhow = "1.0.99" anyhow = "1.0.99"
thiserror = "2.0.16" thiserror = "2.0.16"
itertools = "0.14.0" itertools = "0.14.0"
@ -25,14 +25,14 @@ lazy_static = "1.5.0"
leptos-use = "0.16.2" leptos-use = "0.16.2"
log = "0.4.28" log = "0.4.28"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
reactive_stores = "0.2.5" reactive_stores = "0.4.3"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"] }
console_log = "1.0.0" console_log = "1.0.0"
num-traits = "0.2.19" num-traits = "0.2.19"
num-format = "0.4.4" num-format = "0.4.4"
js-sys = "=0.3.78" js-sys = "=0.3.103"
seq-macro = "0.3.6" seq-macro = "0.3.6"

130
MODERNIZE.md Normal file
View File

@ -0,0 +1,130 @@
# MODERNIZE.md
A list of dependency reductions and idiom cleanups for the current codebase.
Nothing here is applied yet. Review it, then decide which items to do and when
(most fit in ROADMAP Phase 0).
Status notes:
- Every "unused" claim below was verified with a grep: 0 references in `src/`.
- Toolchain decision: stay on nightly (channel = "nightly"). The stable-Rust
item is listed for awareness only.
- The item 3.2 note about the double `foundry.json` load stays true.
- Dependency decision (2026-08-04): `itertools`, `thiserror`, `anyhow`,
`pretty_env_logger`, `dotenvy` stay for certain. The rest of section 1
is under consideration.
## 1. Unused dependencies
None of these are referenced in `src/`. Decision: `itertools`, `thiserror`,
`anyhow`, `pretty_env_logger` and `dotenvy` stay for certain. The rest is
under consideration.
| dependency | note |
|---|---|
| `itertools` | **keep for now** (decision). No usage |
| `seq-macro` | under consideration. No usage |
| `leptos-use` | under consideration. No usage |
| `dotenvy` | **keep for now** (decision). No usage. Never wired into `main` |
| `pretty_env_logger` | **keep for now** (decision). No usage. Logging uses the `log` macros |
| `thiserror` | **keep for now** (decision). No usage. No error types yet |
| `http` | under consideration. Optional, enabled by no feature. Code uses `actix_web::http::StatusCode` |
| `console_log` | under consideration. No usage. Only `console_error_panic_hook` is called |
| `anyhow` | **keep for now** (decision). Only `#[macro_use] extern crate anyhow` and prelude aliases. The `Result` alias and `bail!`/`anyhow!` are used nowhere |
| `js-sys` | under consideration. No direct usage. Resolves transitively. Keep the `wasm-bindgen =0.2.126` pin: `hydrate()` uses `#[wasm_bindgen(...)]` |
## 2. Replace crates with std or small local code
1. **`lazy_static``std::sync::LazyLock`** (stable since 1.80).
Rewrite `src/statics/mod.rs`:
```rust
use std::sync::LazyLock;
static ITEMS_REFS: LazyLock<ItemReferenceMap> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(5_000);
utils::read_items(include_str!("../../data/items.json"), &mut map);
// base.json, foundry.json (twice — duplication bug), magic.json
info!("Loaded {} items", map.len());
ItemReferenceMap(map)
});
static ITEMS_NAMES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| ITEMS_REFS.0.keys().map(String::as_str).collect());
```
Notes:
- `include_str!` needs literal paths, so keep the five explicit calls.
- The current `HashMap::from_iter(map.into_iter())` roundtrip is a no-op.
Wrap the map directly.
- `ITEMS_NAMES` changes from `Vec<&'static String>` to `Vec<&'static str>`.
The matcher accepts both.
2. **`num-format` → local `group_thousands` helper** in `src/utils/mod.rs`.
It exists only for the `ToFormattedString` bound in `Numput`. Drop the
bound and render `f.to_string()` grouped. About 6 lines.
3. **`num-traits` → local saturating trait** in `src/components/numput.rs`.
It exists only for the `SaturatingAdd`/`SaturatingSub` bounds on `Numput`.
Define a small local trait and impl it for `u8`, `u16`, `u32`, `u64`,
`usize`. `Numput` keeps its generics.
4. **`serde` (direct) → remove now, re-add in ROADMAP phase 1.**
No derives exist today; `serde_json::Value` works without naming `serde`
in `Cargo.toml`. Phase 1 (persistence) re-adds
`serde = { version = "1", features = ["derive"] }`.
Result: dropping the under-consideration items takes `[dependencies]` from
27 entries to about 18. If the five kept deps are dropped later too, it goes
to about 13.
## 3. Idiomatic cleanups (no dependency changes)
1. Delete `#![feature(type_alias_impl_trait)]` in `src/lib.rs:6`.
It is unused and warns.
2. Stay on nightly. The code uses only stable features (let-chains, let-else,
edition 2024, `LazyLock`). If the toolchain is ever revisited: drop the
`nightly` features from `leptos`/`leptos_router`, install stable, and run
the three checks. The original build breakage was a nightly regression
(`min_adt_const_params` re-gating in nightly 1.99); stable does not have
that class of failure.
3. Prelude (`src/prelude.rs`):
- Remove the blanket `#![allow(unused_imports)]` and
`#![allow(dead_code)]`. They hide real warnings.
- Remove the single-letter `log` aliases
(`warn as w, log as l, info as i, ...`). They are unused.
- Remove the `anyhow` re-exports and typealiases. Keeping the `anyhow`
dependency does not force keeping these dead re-exports.
- Replace legacy `#[macro_use] extern crate log;` in `src/lib.rs` with
`use log::{info, error};` at the two call sites.
4. `read_items` (`src/utils/mod.rs`): use the `Entry` API instead of
`contains_key``get_mut().unwrap()`. Removes the unwrap and the double
lookup.
5. Mock data (`src/entities/mock.rs`):
`HashSet::from_iter(vec![...])``HashSet::from([...])`.
The date `unwrap`s are fine: the data is static.
6. Minor: `adjust_checked` drops `-> ()`; `numput.rs` drops the unused
`leptos::{ logging, ... }` import.
## 4. Opinionated (do only on request)
1. `chrono``jiff`. Nicer API, actively maintained. Not worth the churn
for this model. If kept: `chrono`'s `serde` feature is unused today —
trim it now, re-add in phase 1.
2. `cargo fmt` normalization. The repo uses `fn foo (` spacing; it may be
deliberate. Ask before running.
3. Clippy pass over the result.
## Verification
After applying items 1-3, run:
```
cargo check --no-default-features --features hydrate --lib
cargo check --no-default-features --features ssr --bin aex
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
```

185
ROADMAP.md Normal file
View File

@ -0,0 +1,185 @@
# ROADMAP — aex
A D&D 5e character sheet and campaign dashboard. One page per concern: character,
inventory, quests, notes, contacts, item reference. Built with Leptos 0.8 (SSR +
hydration), actix-web, SCSS.
## MVP definition
The MVP is done when a player can run a real session with the app:
- the app opens with last session's state (survives tab close / reload)
- the player can track HP, spell slots, hit dice, death saves, money, inventory
(add, remove, move items — including from item search)
- the player can record quests, notes, and contacts as the session happens
- the player can look up any item's stats and add it to inventory
- the player can back up the dashboard to a JSON file and restore it
## Current state (Feb 2026)
| Area | Status |
|---|---|
| Header: campaign name, image, in-game date, session steppers | Works |
| Header: Save / Load / Clear buttons | Dead (no persistence) |
| Sidebar: image, names, class/level/XP, HP, hit dice, death saves, spell slots | Works |
| Character page `/` | Stub — renders literal text "character" |
| Inventory page `/inv`: personal + common, money, item rows | Works |
| Item search (nucleo over ~5k bundled 5e.tools items) | Component exists, wired to no page |
| Item "+" (add) and "…" (details) buttons | Dead |
| Quests / Notes / Contacts | Models + mock data exist, zero UI, no routes |
| Prepared spells | Model exists, list is empty; `Spell` is a `@TODO` stub |
| Attunements | Model exists, no UI |
| Settings | Empty struct |
| Persistence | None — hardcoded `Dashboard::mock()` on every load |
| e2e tests | Example Playwright spec only |
| README | Stock Leptos template |
## Non-goals (explicitly out of MVP)
- accounts, multi-user, or sync across devices
- server-side database
- dice rolling, character builder, full rulebook
- multiple campaigns / dashboards switching
- images hosted by the app (remote URLs are fine)
## Effort legend
- S — ~0.5 day
- M — ~1-2 days
- L — ~3-5 days
---
## Phase 0 — Build hygiene (S, ~0.5-1 day)
Cheap, unblocks the rest. Can happen any time; do it first.
- [ ] rewrite README (currently the stock template)
- [ ] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
- [ ] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
- [ ] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it
- [ ] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps,
`lazy_static``LazyLock`, local replacements for `num-format`/`num-traits`)
- [ ] decide on the under-consideration unused deps from `MODERNIZE.md`
(`seq-macro`, `leptos-use`, `http`, `console_log`, `js-sys`); the rest
(`itertools`, `thiserror`, `anyhow`, `pretty_env_logger`, `dotenvy`) stay
- [ ] replace `end2end/tests/example.spec.ts` with a smoke test (app boots, nav renders, no console errors)
## Phase 1 — Persistence: localStorage + JSON export/import (L, ~3-5 days)
The foundation. Every later phase depends on it.
- [ ] derive `serde::Serialize` / `Deserialize` on all entities in `src/entities/mod.rs`
(Dashboard, PlayerData, Balance, Inventory, InventoryEntry, InventoryItem,
QuestBook, Quest, NoteBook, Note, ContactBook, Contact, SpellSlots, Attunements,
PreparedSpells, Spell, HitDice, DeathSaveThrows, Name, Settings; enums:
InventoryKind, ContactStatus, PreparedSpell)
- [ ] verify `Store` derive and `serde` derive coexist on the same structs (prototype first)
- [ ] add a `version` field to `Settings`; add a migration hook for future format changes
- [ ] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage`
(feature-gate to `hydrate`/`csr`; SSR renders mock/empty)
- [ ] auto-save on change — debounced effect over the `Store` — plus manual Save button
- [ ] Load button: restore from localStorage with an overwrite confirm
- [ ] Clear button: wipe localStorage with a confirm
- [ ] export: download `dashboard.json` as a blob
- [ ] import: file input → parse → validate version → replace state
- [ ] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard"
- **Acceptance:** close the tab, reopen — state is intact. Export → import into a
fresh browser — identical state.
## Phase 2 — Character page (M, ~1-2 days)
`/` currently renders the literal string "character".
- [ ] player card: portrait, name, class, level, XP (data already in sidebar)
- [ ] HP block: temp / current / max, `Numput`-style editing
- [ ] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry
(upgrade the `Spell` stub from `name`-only to name + optional level/description)
- [ ] attunements UI: 3 slots, free text, clear button (model exists)
- **Acceptance:** player can set prepared spells and attunements; they persist.
## Phase 3 — Inventory & item search end-to-end (L, ~3-5 days)
Rows are editable and move/remove works. Search exists but is orphaned; "+" and
"…" buttons are dead.
- [ ] host `ItemSearchField` on a page (Wiki page is the natural home — see Phase 7)
- [ ] "+" adds the `BookItem` to the personal inventory: merge into a row with the
same name, else new row, quantity 1
- [ ] "…" opens an item detail view: description, rarity, weight, tags, source data
(`BookItem.rest`)
- [ ] free-form add-entry UI: add a row (name/quantity/weight) without search
- [ ] edit a row: name, quantity, weight, description
- [ ] stretch: carrying capacity — sum row weights per inventory, show total vs limit
- **Acceptance:** search "potion" → click + → a row appears in the personal
inventory with the correct weight; state persists.
## Phase 4 — Quests page (M, ~1-2 days)
New route `/quests`. Model and mock data exist; zero UI.
- [ ] add a completion state to `Quest` (the model lacks one today)
- [ ] quest list: title, giver (Name), taken/task locations, dates, reward (Balance,
reuse `Numput`), description
- [ ] add / update / delete quest; toggle complete
- [ ] sort by deadline; highlight overdue
- **Acceptance:** create a quest, reload — it is still there; mark it complete.
## Phase 5 — Notes page (M, ~1-2 days)
New route `/notes`. Model and mock data exist; zero UI.
- [ ] note list: content, in-game date, session, tags; add / edit / delete
- [ ] tag chips; filter by tag or by session
- [ ] quick-add defaults to the header's current session and in-game date
- **Acceptance:** log a note mid-session, filter by tag, reload — still there.
## Phase 6 — Contacts page (M, ~1-2 days)
New route `/contacts`. Model and mock data exist; zero UI.
- [ ] contact cards: name, alias, status, location, notes, optional image
- [ ] status filter: Alive / Dead / DeadBygone / Unknown
- [ ] add / edit / delete contact
- **Acceptance:** find a contact by status filter; edit its notes; persists.
## Phase 7 — Wiki page: item reference (M, ~1-2 days)
`/wiki` currently renders "books". Search infrastructure already exists.
- [ ] host `ItemSearchField` here with results list
- [ ] item detail panel: rarity, weight, full entry text from the JSON `rest` field
- [ ] filters: rarity / category tags; lazy list or pagination — never render 5k rows
- [ ] "Add to inventory" shortcut (reuses the Phase 3 path)
- **Acceptance:** browse or search items, open a detail, add to inventory.
## Phase 8 — Polish, tests, release (L, ~2-4 days)
- [ ] responsive SCSS pass (sidebar and header on mobile widths)
- [ ] Playwright e2e: persistence round-trip, add item from search, quest CRUD,
notes filter
- [ ] rewrite README: usage, dev workflow, deploy
- [ ] release build via cargo-leptos; verify SSR + hydration; pick a deploy target
- **Acceptance:** fresh clone → `cargo leptos build` → app runs; e2e green.
---
## Sequencing notes
- Phase 1 before Phases 2-7: every new page must persist. Building pages first
against mock data and persisting later costs a data-migration pass instead.
- Phases 2 and 3 can run in parallel after Phase 1 (different components).
- Phases 4-6 are independent of each other and share one CRUD-list pattern —
build the pattern once (e.g. a generic list editor) and reuse it.
- Phase 7 reuses the Phase 3 add-to-inventory path; keep that path shared.
## Known debt and risks
- **Cargo.lock is gitignored** — builds are not reproducible. Decide in Phase 0.
- **serde × Store derive** on the same structs — unverified combination;
prototype before committing to it (Phase 1).
- **~3.9 MB of item JSON** bundled via `include_str!` — parses on startup in the
browser. Fine for MVP; moving to a server function is a post-MVP option.
- **localStorage** has a ~5 MB budget — plenty for text JSON state.
- **Browser APIs on SSR** — localStorage access must be gated to `hydrate`/`csr`;
SSR renders from mock or empty state.

View File

@ -17,6 +17,12 @@ use crate::{
prelude::* prelude::*
}; };
/// Root component of the app.
///
/// Sets up meta context, creates the `Store<Dashboard>` (mock data for now)
/// and injects it as context, then renders the shell: header, sidebar, nav
/// and the routed page. Routes: `/` character, `/inv` inventories,
/// `/wiki` wiki; anything else falls to `NotFound`.
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc. // Provides context that manages stylesheets, titles, meta tags, etc.

View File

@ -2,6 +2,9 @@ use crate::prelude::*;
use chrono::TimeDelta; use chrono::TimeDelta;
use leptos::prelude::*; use leptos::prelude::*;
/// Campaign header: editable name, campaign image, in-game date and
/// session steppers, and Save/Load/Clear buttons (not wired yet — see
/// ROADMAP phase 1).
#[component] #[component]
pub fn Header () -> impl IntoView { pub fn Header () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -10,10 +13,12 @@ pub fn Header () -> impl IntoView {
let session = state.session(); let session = state.session();
let image = state.campaign_image(); let image = state.campaign_image();
// Shifts the in-game date by ±1 day (saturating, via checked_add_signed).
let adjust_day = move |adjustment: i64| date.update(|d| { let adjust_day = move |adjustment: i64| date.update(|d| {
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap(); *d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
}); });
// Shifts the session counter by ±1, guarding against underflow.
let adjust_session = move |adjustment: isize| session.update(|s| { let adjust_session = move |adjustment: isize| session.update(|s| {
if let Some(new) = s.checked_add_signed(adjustment) { if let Some(new) = s.checked_add_signed(adjustment) {
*s = new; *s = new;

View File

@ -2,6 +2,9 @@ use leptos::{logging, prelude::*};
use reactive_stores::Field; use reactive_stores::Field;
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* }; use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
/// One inventory section: a titled balance block (five `Numput`s) and the
/// item rows. `kind` decides personal/common styling and the move
/// direction.
#[component] #[component]
pub fn Inventory ( pub fn Inventory (
kind: InventoryKind, kind: InventoryKind,
@ -11,10 +14,12 @@ pub fn Inventory (
let state = expect_context::<Context>(); let state = expect_context::<Context>();
// Removes the row at `idx` from this inventory.
let rm = move |idx: usize| inventory.update(|v| { let rm = move |idx: usize| inventory.update(|v| {
v.rows.remove(idx); v.rows.remove(idx);
}); });
// Moves the row at `idx` to the other inventory (personal ↔ common).
let mv = move |idx: usize| { let mv = move |idx: usize| {
let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() { let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() {
InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()), InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()),

View File

@ -3,6 +3,9 @@ use leptos::prelude::*;
use reactive_stores::{Field, OptionStoreExt}; use reactive_stores::{Field, OptionStoreExt};
/// One inventory row: name, quantity stepper + `Numput`, weight stepper +
/// `Numput`, and more/swap/remove buttons. A weight-based tag drives the
/// CSS class. The "…" button is not wired yet (ROADMAP phase 3).
#[component] #[component]
pub fn Item ( pub fn Item (
idx: ReadSignal<usize>, idx: ReadSignal<usize>,
@ -16,10 +19,14 @@ pub fn Item (
let empty = String::new(); let empty = String::new();
// CSS class from the item's weight tag ("weight" key).
let class = move || format!("item {}", item().tags().get().get("weight").unwrap_or(&empty)); let class = move || format!("item {}", item().tags().get().get("weight").unwrap_or(&empty));
// Weight shown as a decimal: basis points → "x.yy".
let weight_display = move || item().weight().with(|f| f.to_decimal_string()); let weight_display = move || item().weight().with(|f| f.to_decimal_string());
// Parses weight input: "+n"/"-n" adjust relative, plain numbers set
// absolutely, anything else restores the current value.
let weight_change = move |ev: Targeted<Event, HtmlInputElement>| { let weight_change = move |ev: Targeted<Event, HtmlInputElement>| {
let old = item().weight().get_untracked(); let old = item().weight().get_untracked();
let new = ev.target().value(); let new = ev.target().value();

View File

@ -1,3 +1,9 @@
//! UI components.
//!
//! `header`, `sidebar` and `nav` form the app shell; `inventory`, `item`
//! and `numput` implement the inventories page; the page components
//! (`Character`, `Inventories`, `Wiki`) and the item search live in this
//! module root.
use leptos::prelude::*; use leptos::prelude::*;
use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher }; use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
use reactive_stores::Field; use reactive_stores::Field;
@ -11,6 +17,8 @@ pub mod inventory;
pub mod numput; pub mod numput;
pub mod item; pub mod item;
/// Character page (route `/`). Stub: renders a placeholder until
/// ROADMAP phase 2.
#[component] #[component]
pub fn Character () -> impl IntoView { pub fn Character () -> impl IntoView {
view! { view! {
@ -18,6 +26,8 @@ pub fn Character () -> impl IntoView {
} }
} }
/// Inventories page (route `/inv`): the player's and the party's
/// inventories side by side, each with money and item rows.
#[component] #[component]
pub fn Inventories () -> impl IntoView { pub fn Inventories () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -40,6 +50,8 @@ pub fn Inventories () -> impl IntoView {
} }
} }
/// Wiki page (route `/wiki`). Stub: renders a placeholder until
/// ROADMAP phase 7.
#[component] #[component]
pub fn Wiki () -> impl IntoView { pub fn Wiki () -> impl IntoView {
view! { view! {
@ -47,6 +59,11 @@ pub fn Wiki () -> impl IntoView {
} }
} }
/// Fuzzy item search over the bundled item names.
///
/// Re-matches on every keystroke (nucleo) and renders result cards. The
/// "+" buttons on the cards are not wired to the inventory yet (ROADMAP
/// phase 3).
#[component] #[component]
pub fn ItemSearchField () -> impl IntoView { pub fn ItemSearchField () -> impl IntoView {
let (query, set_query) = signal("".to_owned()); let (query, set_query) = signal("".to_owned());
@ -86,6 +103,7 @@ pub fn ItemSearchField () -> impl IntoView {
} }
/// One search result: name, weight, rarity and an (unwired) add button.
#[component] #[component]
pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView { pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView {
let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new())); let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new()));

View File

@ -2,6 +2,7 @@ use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::components::A; use leptos_router::components::A;
/// Top navigation: links to the three routes (character, inventory, wiki).
#[component] #[component]
pub fn Nav () -> impl IntoView { pub fn Nav () -> impl IntoView {
view! { view! {

View File

@ -5,6 +5,11 @@ use num_format::{Locale, ToFormattedString};
use num_traits::{ SaturatingSub, SaturatingAdd }; use num_traits::{ SaturatingSub, SaturatingAdd };
use crate::prelude::*; use crate::prelude::*;
/// Number input bound to a reactive field.
///
/// `+n` / `-n` adjust the current value relatively, a plain number sets it
/// absolutely, and anything else is rejected (the input is restored). The
/// value renders with thousands separators (`num_format`).
#[component] #[component]
pub fn Numput <T, U, 'a> ( pub fn Numput <T, U, 'a> (
field: U, field: U,
@ -37,6 +42,11 @@ pub fn Numput <T, U, 'a> (
} }
} }
/// `Numput` with caller-supplied display and change handlers.
///
/// For fields whose display differs from the stored value (e.g. weights
/// in basis points): `field_with` renders the input, `change` parses the
/// edit.
#[component] #[component]
pub fn NumputChange <U, G, 'a> ( pub fn NumputChange <U, G, 'a> (
mut field_with: U, mut field_with: U,

View File

@ -1,3 +1,5 @@
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
//! death saves and spell slots.
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
@ -9,6 +11,8 @@ mod hit_dice;
mod death_saving_throws; mod death_saving_throws;
mod spell_slots; mod spell_slots;
/// Left-hand character panel. Reads `Context` and stacks the sub-panels
/// (image, names, about, HP, hit dice, death saves, spell slots).
#[component] #[component]
pub fn Sidebar () -> impl IntoView { pub fn Sidebar () -> impl IntoView {
view! { view! {

View File

@ -1,6 +1,7 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Class (text input), level (+/ steppers) and XP (number input).
#[component] #[component]
pub fn About () -> impl IntoView { pub fn About () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -10,12 +11,14 @@ pub fn About () -> impl IntoView {
let level = player.level(); let level = player.level();
let xp = player.xp(); let xp = player.xp();
// Steps the level by ±1, guarding against overflow.
let adjust_level = move |adjustment: i8| level.update(|l| { let adjust_level = move |adjustment: i8| level.update(|l| {
if let Some(new) = l.checked_add_signed(adjustment) { if let Some(new) = l.checked_add_signed(adjustment) {
*l = new; *l = new;
} }
}); });
// Applies the XP input with checked parsing (invalid text is reverted).
let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| { let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, xp); utils::adjust_checked(ev, xp);
}; };

View File

@ -1,6 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Three failure pips and three success pips, plus a reset button.
/// Clicking a pip fills it; clicking the last filled pip empties it.
#[component] #[component]
pub fn DeathSavingThrows () -> impl IntoView { pub fn DeathSavingThrows () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -8,6 +10,7 @@ pub fn DeathSavingThrows () -> impl IntoView {
let dt = player.death_save_throws(); let dt = player.death_save_throws();
// Toggles pip `slot_id` of the fail/success track.
let process_dt_click = move |success: bool, slot_id: u8| { let process_dt_click = move |success: bool, slot_id: u8| {
dt.update(|d| { dt.update(|d| {
let v = if success { &mut d.succeeded } else { &mut d.failed }; let v = if success { &mut d.succeeded } else { &mut d.failed };

View File

@ -1,6 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Hit dice: a die-kind selector (d4 … d20) and one clickable die per
/// character level; clicking toggles spent/unspent.
#[component] #[component]
pub fn HitDice () -> impl IntoView { pub fn HitDice () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -8,6 +10,8 @@ pub fn HitDice () -> impl IntoView {
let hit_dice = player.hit_dice(); let hit_dice = player.hit_dice();
let level = player.level(); let level = player.level();
// Toggles die `slot_id` spent/unspent; clicking the last spent die
// frees it again.
let process_die_click = move |slot_id: u8| { let process_die_click = move |slot_id: u8| {
hit_dice.update(|d| { hit_dice.update(|d| {
if slot_id < d.used { if slot_id < d.used {

View File

@ -1,6 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Current/max HP with a fill bar, plus temp HP with its own bar.
/// All three numbers are editable `type="number"` inputs.
#[component] #[component]
pub fn HitPoints () -> impl IntoView { pub fn HitPoints () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -10,6 +12,8 @@ pub fn HitPoints () -> impl IntoView {
let max_hp = player.max_hp(); let max_hp = player.max_hp();
let temp_hp = player.temp_hp(); let temp_hp = player.temp_hp();
// All three inputs parse via `utils::adjust_checked`; invalid text
// restores the previous value.
let adjust_hp = move |ev: Targeted<Event, HtmlInputElement>| { let adjust_hp = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, hp); utils::adjust_checked(ev, hp);
}; };

View File

@ -1,6 +1,7 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Character portrait; hidden while no image URL is set.
#[component] #[component]
pub fn Image () -> impl IntoView { pub fn Image () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();

View File

@ -1,6 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// First name, alias (in «»), last name — three text inputs bound to the
/// player's name store.
#[component] #[component]
pub fn Names () -> impl IntoView { pub fn Names () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();

View File

@ -1,6 +1,8 @@
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Spell slots for all nine levels: a row of clickable pips per level.
/// Clicking toggles a slot used/unused.
#[component] #[component]
pub fn SpellSlots () -> impl IntoView { pub fn SpellSlots () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -8,6 +10,7 @@ pub fn SpellSlots () -> impl IntoView {
let slots = player.spell_slots(); let slots = player.spell_slots();
// Toggles pip `slot_id` of `level` used/unused.
let process_spell_slot_click = move |level: usize, slot_id: u8| { let process_spell_slot_click = move |level: usize, slot_id: u8| {
slots.update(|s| { slots.update(|s| {
let l = &mut s.0[level]; let l = &mut s.0[level];

View File

@ -1,7 +1,14 @@
//! Hardcoded demo data.
//!
//! `Dashboard::mock()` is the app's only state source until persistence
//! lands (ROADMAP phase 1).
use crate::prelude::*; use crate::prelude::*;
use super::*; use super::*;
impl Dashboard { impl Dashboard {
/// Builds a fully populated demo dashboard: a level-8 character, two
/// inventories, quests, notes and contacts. Used at startup and as the
/// fallback state.
pub fn mock () -> Self { pub fn mock () -> Self {
let (p_inv, c_inv) = generate_mock_inventory(); let (p_inv, c_inv) = generate_mock_inventory();
@ -47,6 +54,8 @@ impl Dashboard {
/// Demo inventories: 10 personal items and 10 common items, each with a
/// weight, description and tags.
fn generate_mock_inventory() -> (Inventory, Inventory) { fn generate_mock_inventory() -> (Inventory, Inventory) {
let a = vec![ let a = vec![
InventoryEntry { id: 1, quantity: 1, item: InventoryItem { name: "Vorpal Sword".to_string(), weight: 400, description: Some("A blade that severs heads with a single swing.".to_string()), tags: HashMap::from([ ("type".to_string(), "weapon".to_string()), ("rarity".to_string(), "legendary".to_string()), ]), }, }, InventoryEntry { id: 1, quantity: 1, item: InventoryItem { name: "Vorpal Sword".to_string(), weight: 400, description: Some("A blade that severs heads with a single swing.".to_string()), tags: HashMap::from([ ("type".to_string(), "weapon".to_string()), ("rarity".to_string(), "legendary".to_string()), ]), }, },
@ -79,6 +88,8 @@ fn generate_mock_inventory() -> (Inventory, Inventory) {
/// Demo notes: one per session, with in-game dates, real timestamps and
/// tags.
fn generate_mock_notebook () -> NoteBook { fn generate_mock_notebook () -> NoteBook {
NoteBook(vec![ NoteBook(vec![
Note { session: 1, content: "Met Elminster in Waterdeep; hinted at Netherese ruin.".to_string(), date_ingame: NaiveDate::from_ymd_opt(1481, 4, 15).unwrap(), date_real: NaiveDateTime::parse_from_str("2025-09-01 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap(), tags: HashSet::from_iter(vec!["Elminster".to_string(), "Waterdeep".to_string()]) }, Note { session: 1, content: "Met Elminster in Waterdeep; hinted at Netherese ruin.".to_string(), date_ingame: NaiveDate::from_ymd_opt(1481, 4, 15).unwrap(), date_real: NaiveDateTime::parse_from_str("2025-09-01 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap(), tags: HashSet::from_iter(vec!["Elminster".to_string(), "Waterdeep".to_string()]) },
@ -96,6 +107,7 @@ fn generate_mock_notebook () -> NoteBook {
]) ])
} }
/// Demo quests with givers, locations, dates and rewards.
fn generate_mock_questbook () -> QuestBook { fn generate_mock_questbook () -> QuestBook {
QuestBook(vec![ QuestBook(vec![
Quest { title: "Slay the Bandit Chief".to_string(), location_taken: Some("Waterdeep".to_string()), location_task: Some("Phandalin".to_string()), date_taken: Some(NaiveDate::from_ymd_opt(1481, 4, 15).unwrap()), date_by: Some(NaiveDate::from_ymd_opt(1481, 5, 15).unwrap()), description: Some("Kill the bandit leader.".to_string()), from: Some(Name { first: "Dagult".to_string(), last: "Neverember".to_string(), alias: "".to_string(), ..Default::default() }), reward: Some(Balance { platinum: 0, gold: 100, electrum: 0, silver: 0, copper: 0 }) }, Quest { title: "Slay the Bandit Chief".to_string(), location_taken: Some("Waterdeep".to_string()), location_task: Some("Phandalin".to_string()), date_taken: Some(NaiveDate::from_ymd_opt(1481, 4, 15).unwrap()), date_by: Some(NaiveDate::from_ymd_opt(1481, 5, 15).unwrap()), description: Some("Kill the bandit leader.".to_string()), from: Some(Name { first: "Dagult".to_string(), last: "Neverember".to_string(), alias: "".to_string(), ..Default::default() }), reward: Some(Balance { platinum: 0, gold: 100, electrum: 0, silver: 0, copper: 0 }) },
@ -111,6 +123,8 @@ fn generate_mock_questbook () -> QuestBook {
]) ])
} }
/// Demo contacts: mostly NPCs met by the party, plus a few merchants,
/// living legends and the dead.
fn generate_mock_contact_book () -> ContactBook { fn generate_mock_contact_book () -> ContactBook {
let mut contacts = HashMap::new(); let mut contacts = HashMap::new();
// 40 Characters Met by Party (80%) // 40 Characters Met by Party (80%)
@ -172,6 +186,7 @@ fn generate_mock_contact_book () -> ContactBook {
} }
/// Demo prepared spells. Currently an empty list — `Spell` is a stub.
fn generate_prepared_spells_list () -> PreparedSpells { fn generate_prepared_spells_list () -> PreparedSpells {
use PreparedSpell::*; use PreparedSpell::*;
let list = vec![ let list = vec![

View File

@ -1,3 +1,16 @@
//! The domain model of the campaign dashboard.
//!
//! Every type here is a plain data struct that derives
//! [`reactive_stores::Store`], which makes each field independently
//! reactive: components subscribe to single fields and re-render only when
//! those change. The root type is [`Dashboard`], wrapped in a
//! `Store<Dashboard>` and provided to the whole app through Leptos context
//! (see `crate::prelude::Context`).
//!
//! The model is deliberately "wide": it covers the campaign header, the
//! player character, the party's common stash, quests, notes, contacts and
//! settings. All of it currently comes from hardcoded mock data (the `mock`
//! module); nothing is persisted yet (see ROADMAP phase 1).
use std::fmt::Display; use std::fmt::Display;
use chrono::{ NaiveDate, NaiveDateTime }; use chrono::{ NaiveDate, NaiveDateTime };
@ -7,6 +20,10 @@ use crate::prelude::*;
mod mock; mod mock;
/// A single item as it appears in the bundled 5e.tools JSON files.
///
/// `rest` keeps every other JSON field verbatim, so the raw source data
/// survives lookup (used by future item detail views).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct BookItem { pub struct BookItem {
pub name: String, pub name: String,
@ -16,6 +33,8 @@ pub struct BookItem {
} }
impl BookItem { impl BookItem {
/// Parses one 5e.tools JSON object into a `BookItem`.
/// Returns `None` when the object has no string `name` or is not an object.
pub fn from_json (t: Value) -> Option<Self> { pub fn from_json (t: Value) -> Option<Self> {
let Value::String(name) = t["name"].clone() else { return None }; let Value::String(name) = t["name"].clone() else { return None };
let weight = match t["weight"].clone() { let weight = match t["weight"].clone() {
@ -36,9 +55,17 @@ impl BookItem {
} }
} }
/// Name → `BookItem` lookup, built once from the bundled JSON (see `statics`).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct ItemReferenceMap (pub HashMap<String, BookItem>); pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
/// The root application state.
///
/// One `Dashboard` holds everything the UI edits: campaign header data
/// (name, image, session number, in-game date), the player character, the
/// party's common inventory, quests, notes, contacts and settings. The
/// whole tree derives `Store`, so every nested field is independently
/// reactive. `Dashboard::mock()` provides the initial state.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Dashboard { pub struct Dashboard {
pub campaign: String, pub campaign: String,
@ -53,6 +80,9 @@ pub struct Dashboard {
pub settings: Settings pub settings: Settings
} }
/// The player character: identity, class, level, XP, hit points, money,
/// spell slots, inventory, attunements, prepared spells, hit dice and
/// death saves. This is the heart of the character sheet.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct PlayerData { pub struct PlayerData {
pub name: Name, pub name: Name,
@ -74,12 +104,15 @@ pub struct PlayerData {
} }
/// The party's shared stash: money and items usable by everyone.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct CommonData { pub struct CommonData {
pub balance: Balance, pub balance: Balance,
pub inventory: Inventory, pub inventory: Inventory,
} }
/// Currency counts per denomination, platinum through copper.
/// Edited with `Numput` on the inventories page.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Balance { pub struct Balance {
pub platinum: u32, pub platinum: u32,
@ -89,12 +122,17 @@ pub struct Balance {
pub copper: u32, pub copper: u32,
} }
/// A named inventory (personal or common): a list of entries.
///
/// `#[store(key)]` gives every entry a stable reactive key (its `id`),
/// which keeps `For`/`ForEnumerate` updates cheap.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Inventory { pub struct Inventory {
#[store(key: u64 = |row| row.id)] #[store(key: u64 = |row| row.id)]
pub rows: Vec<InventoryEntry> pub rows: Vec<InventoryEntry>
} }
/// One inventory row: a quantity of a single item.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryEntry { pub struct InventoryEntry {
pub id: u64, pub id: u64,
@ -103,6 +141,7 @@ pub struct InventoryEntry {
} }
impl InventoryEntry { impl InventoryEntry {
/// Returns a stable key string for this entry (quantity + item name).
pub fn key (&self) -> String { pub fn key (&self) -> String {
format!("{}-{}", self.quantity, self.item.name) format!("{}-{}", self.quantity, self.item.name)
} }
@ -114,6 +153,8 @@ impl InventoryEntry {
// Book (Item), // Book (Item),
// } // }
/// An item inside an inventory: name, weight (basis points), optional
/// description, and free-form tags (e.g. `type`, `rarity`).
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryItem { pub struct InventoryItem {
pub name: String, pub name: String,
@ -122,9 +163,12 @@ pub struct InventoryItem {
pub tags: HashMap<String, String>, pub tags: HashMap<String, String>,
} }
/// The party's quest list. No UI yet — see ROADMAP phase 4.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct QuestBook (pub Vec<Quest>); pub struct QuestBook (pub Vec<Quest>);
/// One quest: title, locations, dates, description, giver and reward.
/// Note: there is no completion state yet (ROADMAP phase 4 adds one).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Quest { pub struct Quest {
pub title: String, pub title: String,
@ -137,9 +181,12 @@ pub struct Quest {
pub reward: Option<Balance>, pub reward: Option<Balance>,
} }
/// The party's session notes. No UI yet — see ROADMAP phase 5.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct NoteBook (pub Vec<Note>); pub struct NoteBook (pub Vec<Note>);
/// One note: content, in-game date, real-world timestamp, session number
/// and tags.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Note { pub struct Note {
pub content: String, pub content: String,
@ -149,35 +196,43 @@ pub struct Note {
pub tags: HashSet<String>, pub tags: HashSet<String>,
} }
/// Spell slots for the nine spell levels (index 0 = 1st level).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct SpellSlots (pub [SpellSlotLevel; 9]); pub struct SpellSlots (pub [SpellSlotLevel; 9]);
/// Used/total slots for one spell level.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct SpellSlotLevel { pub struct SpellSlotLevel {
pub used: u8, pub used: u8,
pub total: u8, pub total: u8,
} }
/// Three attunement slots (D&D rules allow three attuned items).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Attunements (pub [Option<String>; 3]); pub struct Attunements (pub [Option<String>; 3]);
/// The spell list the character has prepared (or is learning).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct PreparedSpells (pub Vec<PreparedSpell>); pub struct PreparedSpells (pub Vec<PreparedSpell>);
/// One prepared-spell slot: vacant or holding a spell.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub enum PreparedSpell { pub enum PreparedSpell {
Vacant, Vacant,
Occupied (Spell) Occupied (Spell)
} }
/// A spell. Only the name exists so far; the rest is a `@TODO`
/// (see ROADMAP phase 2).
// @TODO // @TODO
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Spell { pub struct Spell {
pub name: String pub name: String
} }
/// Hit dice: how many of the current die kind are already spent.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct HitDice { pub struct HitDice {
pub used: u8, pub used: u8,
@ -185,6 +240,8 @@ pub struct HitDice {
} }
/// Death-saving-throw counters: three failures kill, three successes
/// stabilize.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct DeathSaveThrows { pub struct DeathSaveThrows {
pub failed: u8, pub failed: u8,
@ -192,15 +249,18 @@ pub struct DeathSaveThrows {
} }
impl DeathSaveThrows { impl DeathSaveThrows {
/// Clears both counters. Bound to the ⟳ button in the sidebar.
pub fn reset (&mut self) { pub fn reset (&mut self) {
self.failed = 0; self.failed = 0;
self.succeeded = 0; self.succeeded = 0;
} }
} }
/// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct ContactBook (pub HashMap<String, Contact>); pub struct ContactBook (pub HashMap<String, Contact>);
/// One NPC contact: name, optional image, status, location and notes.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Contact { pub struct Contact {
pub name: Name, pub name: Name,
@ -210,6 +270,7 @@ pub struct Contact {
pub notes: Option<String>, pub notes: Option<String>,
} }
/// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub enum ContactStatus { pub enum ContactStatus {
Unknown, Unknown,
@ -218,6 +279,7 @@ pub enum ContactStatus {
Alive, Alive,
} }
/// A person's name: first, last, and an optional alias (nickname).
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct Name { pub struct Name {
pub first: String, pub first: String,
@ -226,26 +288,31 @@ pub struct Name {
} }
impl Name { impl Name {
/// True when an alias is set.
pub fn display_alias (&self) -> bool { pub fn display_alias (&self) -> bool {
!self.alias.is_empty() !self.alias.is_empty()
} }
/// True when a last name is set.
pub fn display_last (&self) -> bool { pub fn display_last (&self) -> bool {
!self.last.is_empty() !self.last.is_empty()
} }
} }
/// App settings. Empty today; will carry e.g. a persisted-format version.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct Settings { pub struct Settings {
} }
/// Which side of the party an inventory belongs to.
#[derive(Clone, Copy, Debug, Store)] #[derive(Clone, Copy, Debug, Store)]
pub enum InventoryKind { pub enum InventoryKind {
Personal, Personal,
Common Common
} }
/// Russian display name ("Личный" / "Общий").
impl Display for InventoryKind { impl Display for InventoryKind {
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self { write!(f, "{}", match self {
@ -256,6 +323,7 @@ impl Display for InventoryKind {
} }
impl InventoryKind { impl InventoryKind {
/// The other inventory (for the move-between button).
pub fn dst (&self) -> Self { pub fn dst (&self) -> Self {
match self { match self {
Self::Common => Self::Personal, Self::Common => Self::Personal,
@ -263,6 +331,7 @@ impl InventoryKind {
} }
} }
/// CSS classes for the inventory section (personal vs common styling).
pub fn class (&self) -> &'static str { pub fn class (&self) -> &'static str {
match self { match self {
Self::Common => "inventory inventory-common", Self::Common => "inventory inventory-common",

View File

@ -1,3 +1,8 @@
//! aex — a D&D 5e character sheet and campaign dashboard.
//!
//! Leptos 0.8 app with actix-web SSR and client-side hydration. The crate
//! root declares the modules and the WASM entry point; `main.rs` holds the
//! SSR server, `app.rs` the component tree.
#![feature(type_alias_impl_trait)] #![feature(type_alias_impl_trait)]
pub mod app; pub mod app;
@ -21,6 +26,8 @@ mod components;
#[allow(unused_imports)] #[allow(unused_imports)]
use prelude::*; use prelude::*;
/// WASM entry point: hydrates `App` into the server-rendered DOM.
/// Only compiled with the `hydrate` feature.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() { pub fn hydrate() {

View File

@ -1,4 +1,9 @@
/// SSR entry point: starts the actix-web server that renders the app.
///
/// Serves the WASM/CSS bundle from `/pkg`, static assets from `/assets`,
/// and renders every route through `leptos_routes`. Only compiled with
/// the `ssr` feature.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
@ -58,6 +63,7 @@ async fn main() -> std::io::Result<()> {
.await .await
} }
/// Serves `/favicon.ico` from the site root.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[actix_web::get("favicon.ico")] #[actix_web::get("favicon.ico")]
async fn favicon( async fn favicon(
@ -70,6 +76,8 @@ async fn favicon(
))?) ))?)
} }
/// No-op `main` for builds without `ssr` or `csr`: the WASM binary is
/// hydrated from `lib.rs` instead.
#[cfg(not(any(feature = "ssr", feature = "csr")))] #[cfg(not(any(feature = "ssr", feature = "csr")))]
pub fn main() { pub fn main() {
// no client-side main function // no client-side main function
@ -78,6 +86,8 @@ pub fn main() {
// see optional feature `csr` instead // see optional feature `csr` instead
} }
/// Client-only `main` for `trunk serve --features csr`.
/// Not the normal path — prefer `cargo leptos serve`.
#[cfg(all(not(feature = "ssr"), feature = "csr"))] #[cfg(all(not(feature = "ssr"), feature = "csr"))]
pub fn main() { pub fn main() {
// a client-side main function is required for using `trunk serve` // a client-side main function is required for using `trunk serve`

View File

@ -1,3 +1,7 @@
//! Crate-wide re-exports.
//!
//! Components and entities use `use crate::prelude::*` to get the common
//! imports: collections, logging, error aliases, `Store`, entities, utils.
#![allow(unused_imports)] #![allow(unused_imports)]
#![allow(dead_code)] #![allow(dead_code)]
pub use std::collections::{ HashMap, HashSet }; pub use std::collections::{ HashMap, HashSet };
@ -16,6 +20,12 @@ pub (crate) use crate::{
components::{ self, numput::{ Numput, NumputChange } }, components::{ self, numput::{ Numput, NumputChange } },
}; };
/// Crate-wide error type. Alias of `anyhow::Error`.
pub type Error = anyhow::Error; pub type Error = anyhow::Error;
/// Crate-wide result type. Alias of `anyhow::Result<T>`.
pub type Result <T> = anyhow::Result<T>; pub type Result <T> = anyhow::Result<T>;
/// The reactive application state: a `Store` wrapping [`crate::entities::Dashboard`].
///
/// Created once in `App` and injected through Leptos context. Components
/// read it with `expect_context::<Context>()` and grab reactive field handles.
pub type Context = Store<Dashboard>; pub type Context = Store<Dashboard>;

View File

@ -1 +1,6 @@
//! App state.
//!
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
//! (alias `Context`), created in `App` and shared through Leptos context.
use crate::prelude::*; use crate::prelude::*;

View File

@ -1,8 +1,18 @@
//! Bundled static game data.
//!
//! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`,
//! roughly 5k items) once at first use via `lazy_static` and indexes them
//! by name. `ITEMS_NAMES` is the flat name list used for fuzzy search.
use crate::prelude::*; use crate::prelude::*;
use lazy_static::lazy_static; use lazy_static::lazy_static;
lazy_static! { lazy_static! {
/// All known items, indexed by name, from the bundled 5e.tools JSON files.
///
/// Built once at startup: entries with the same name are merged (a missing
/// weight fills in, `rest` fields concatenate). Note `foundry.json` is
/// currently loaded twice — see `utils::read_items`.
pub static ref ITEMS_REFS: ItemReferenceMap = { pub static ref ITEMS_REFS: ItemReferenceMap = {
let mut map = HashMap::with_capacity(5_000); let mut map = HashMap::with_capacity(5_000);
@ -17,6 +27,8 @@ lazy_static! {
ItemReferenceMap(HashMap::from_iter(map.into_iter())) ItemReferenceMap(HashMap::from_iter(map.into_iter()))
}; };
/// Flat list of item names; the feed for the fuzzy matcher
/// (`ItemSearchField`).
pub static ref ITEMS_NAMES: Vec<&'static String> = { pub static ref ITEMS_NAMES: Vec<&'static String> = {
ITEMS_REFS.0.keys().collect() ITEMS_REFS.0.keys().collect()
}; };

View File

@ -1,3 +1,5 @@
//! Small shared helpers: item parsing, checked input parsing, percentage
//! math, and the `BasisPoints` display trait.
use crate::prelude::*; use crate::prelude::*;
use std::str::FromStr; use std::str::FromStr;
use leptos::ev::{ Event, Targeted }; use leptos::ev::{ Event, Targeted };
@ -5,6 +7,11 @@ use leptos::web_sys::HtmlInputElement;
use leptos::reactive::traits::{ Get, Set }; use leptos::reactive::traits::{ Get, Set };
/// Parses a 5e.tools-format JSON array of items into `map`, keyed by name.
///
/// Skips entries without a string `name` or without an object body.
/// Duplicate names merge: the later entry's weight fills a missing one and
/// its `rest` fields are appended. Returns the number of new names added.
pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize { pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize {
let Ok(json): std::result::Result<Value, _> = serde_json::from_str(&contents) else { error!("Can't read Value from contents"); return 0 }; let Ok(json): std::result::Result<Value, _> = serde_json::from_str(&contents) else { error!("Can't read Value from contents"); return 0 };
let Value::Array(list) = json else { error!("Value isn't Array, skipping"); return 0 }; let Value::Array(list) = json else { error!("Value isn't Array, skipping"); return 0 };
@ -24,6 +31,10 @@ pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize
map.len() - start map.len() - start
} }
/// Applies a text input's value to a reactive store with checked parsing.
///
/// On a successful parse, sets the store to the value; otherwise restores
/// the input to the store's current value. Used for `type="number"` inputs.
pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -> () pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -> ()
where where
U: Set<Value = T> + Get<Value = T>, U: Set<Value = T> + Get<Value = T>,
@ -39,6 +50,8 @@ pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -
} }
} }
/// Percent of `cur` relative to `max`; callers clamp to 100. Used for
/// HP-bar widths.
pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> { pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
let cur: f32 = cur.into(); let cur: f32 = cur.into();
let max: f32 = max.into(); let max: f32 = max.into();
@ -46,10 +59,13 @@ pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
} }
/// Formats an integer stored in basis points (hundredths) as a decimal
/// string, e.g. `400` → `"4.00"`.
pub trait BasisPoints { pub trait BasisPoints {
fn to_decimal_string (&self) -> String; fn to_decimal_string (&self) -> String;
} }
/// Weights are stored as `u32` basis points: 100 = 1.0.
impl BasisPoints for u32 { impl BasisPoints for u32 {
fn to_decimal_string (&self) -> String { fn to_decimal_string (&self) -> String {
let int = self / 100; let int = self / 100;