359 Commits

Author SHA1 Message Date
Scott Idem
246d4f8ef3 fix(pres_mgmt): show session/presenter counts in File Downloads header
The summary was showing file counts by type (session-level files vs
presenter-level files), which made the session count look wrong (e.g.
6 when there are 40 sessions). Now shows unique session and presenter
counts from the grouped data, matching what the label implies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:30:19 -04:00
Scott Idem
666b54bd36 fix(badges): add missing updated_on column to CSV export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:36:25 -04:00
Scott Idem
89c1decf8d feat(badges): add CSV export to badge reports
Adds an "Export CSV" report to the badge reports page. Generates
a clean CSV client-side from Dexie cache — no backend call needed.

- 3 filter presets: Printed+Clean (default), Printed all, All badges
- Printed+Clean mirrors the manually-cleaned Axonius export (printed,
  non-hidden, non-test badge type)
- Timezone selector: Eastern (default), Local, UTC — addresses the
  UTC→Eastern conversion needed for post-event client exports
- 24 columns: identity fields, override pairs, print status, created_on
- UTF-8 BOM for direct Excel open without import wizard
- Auto-generated filename from event name + date + filter suffix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:00:18 -04:00
Scott Idem
b9d70b616f feat(reports): add two new filename format options to file downloads report
Adds 'Session Code + Date + Time + Name' and 'Session Code + Date +
Session Time + Presentation Time + Presenter Full Name' format presets.
The second format uses presentation start datetime for the pres_time
part and event_presenter_full_name for the full presenter name part.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:18:39 -04:00
Scott Idem
e8a49562a9 feat(pres_mgmt): prefix/suffix inputs, flex row layout, strip :443 from links
File Downloads report: add Prefix and Suffix inputs for filename customization
(e.g. "2026_06__"), longest-filename length indicator with warning above 120
chars, and switch file rows from table to flex layout so Download/Copy Link
buttons stay right-aligned regardless of filename length.

Strip redundant :443 from https download URLs in both the file downloads report
and the manage event file list clipboard links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:15:16 -04:00
Scott Idem
e909c34874 Updated documentation. 2026-06-10 14:55:10 -04:00
Scott Idem
48bc52899f fix(pres_mgmt): switch direct download links to event_file endpoint
event_file ?key= auth fix is now deployed. Retire the hosted_file workaround
and use /v3/action/event_file/{event_file_id}/download?key=... as the canonical
form across the file downloads report, manage file list, and download button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:41:53 -04:00
Scott Idem
6b122a065e feat(pres_mgmt): File Downloads report with clean filename presets
New report at Pres Mgmt > Reports > File Downloads. Groups all event files
by session and presenter, with 10 filename format presets (original, session
code/date/name combos, presenter variants). Per-file Download button and Copy
Link button using hosted_file endpoint for ?key= auth support.

Also fixes direct download links in element_manage_event_file_li and the
hosted_files download button — event_file endpoint does not yet propagate
?key= auth internally, so all direct links now use hosted_file endpoint
which supports it today.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:19:25 -04:00
Scott Idem
1b81b8873c refactor(badges): move reports IDB prefetch to +page.ts load function
Replace the $effect-based background fetch with the canonical
if(browser) fire-and-forget pattern in +page.ts. Runs earlier
in the lifecycle, no store subscription overhead, and immune to
$ae_api store re-trigger side effects. Removes ae_api and
events_func imports from the component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:36:31 -04:00
Scott Idem
4f74cf1353 fix(badges): reports background fetch guard used wrong field name
$ae_api.api_key does not exist — the field is api_secret_key.
The guard evaluated to true on every render, returning immediately
and never calling search__event_badge. Changed to base_url which
is the standard readiness check per the quickstart doc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:32:49 -04:00
Scott Idem
6dc6be9926 fix(badges): reports background fetch was not writing to IDB
try_cache=false skips db_save_ae_obj_li__ae_obj, so the API fetch
completed but liveQuery never saw the data. Removing the override
lets it default to true so results are persisted to IDB as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:27:57 -04:00
Scott Idem
97c4c1cd6b fix(badges): Remote First uncheck stuck visually checked
e.preventDefault() was called for both enable and disable clicks.
On disable, it reverted the DOM back to checked before Svelte could
sync the store update, leaving it visually stuck. Only prevent default
when enabling (to hold the unchecked state during confirmation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:15:39 -04:00
Scott Idem
b6481a3507 feat(badges): Remote First confirmation warning + title tooltips
Enabling Remote First now shows an inline warning explaining that it
bypasses the local cache and queries the server on every keystroke.
User must confirm before it activates; Cancel leaves the checkbox off.
Disabling still takes effect immediately. Label and checkbox also get
title tooltips. Active state gets a warning tonal highlight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:12:28 -04:00
Scott Idem
7b45b548e4 fix(badges): reports page fetches all badges on load instead of IDB-only
Reports were IDB-read-only — no data appeared until the badge search page
had already populated the cache. Added a background search__event_badge
call (limit 5000, try_cache=false) so navigating directly to /reports
always gets a fresh full dataset; liveQuery updates reactively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:06:13 -04:00
Scott Idem
a1057fd776 fix(badges): throughput — weekday-first date header, keyed each block
Separators now read "Tuesday · June 9 — EDT" so day-of-week is the
lead identifier, matching how conference staff refer to the schedule.
Added key (sz) to the window-size each block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:28:48 -04:00
Scott Idem
d0286f7868 feat(badges): show full date + browser timezone in throughput separators
Date separators now display "Monday, June 9 — EDT" instead of "Jun 9",
using the browser's local timezone abbreviation resolved at page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:24:25 -04:00
Scott Idem
1c541cd090 fix(badges): print throughput report — descending sort + UTC timezone fix
Buckets now display newest-first. Naive UTC datetime strings from the
backend are normalized with a Z suffix before parsing so times display
in local browser timezone, matching the badge list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:16:14 -04:00
Scott Idem
868b4017f2 feat(badges): allow trusted staff to edit name without enabling edit mode
Trusted users can already edit title/affiliations in regular mode via the
auth_editable list. Name was locked to trusted+edit_mode only. Staff at the
badge table frequently need to fix a misspelled name mid-queue without
toggling the full edit mode for the whole app.

Added a targeted guard in field_editable(): is_trusted && field === 'name'
passes before the auth_editable list check, so trusted users get the edit
pencil for name in normal mode. Regular authenticated users (attendees)
are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:38:48 -04:00
Scott Idem
3122725610 fix(badges): apply print_count-first sort to all three badge display paths
The default sort (unprinted first, then name) was only wired into the API
order_by_li. Two IDB-local paths were left behind:

1. SCENARIO 2 fallback (no active filters, no text query): used Dexie
   .sortBy('given_name') — bypassed entirely on initial page load.
   Fixed: fetch .toArray() then JS-sort by print_count ASC → given_name ASC.

2. Fast-path IDB sort default case: also sorted by given_name only, causing
   a visible flash of name-ordered results before the API response landed.
   Fixed: same print_count ASC → given_name ASC comparator.

All three paths (API, fast-path IDB, fallback IDB) now agree on sort order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:13:47 -04:00
Scott Idem
6e04145514 fix(badges): display print timestamps in local browser time instead of UTC
The backend returns naive UTC datetime strings (no Z suffix). dayjs was treating
them as local time, so staff in ET saw UTC times. Added treat_as_utc param to
iso_datetime_formatter (default false, zero breakage to existing call sites) that
appends Z before parsing so dayjs converts to browser-local time on display.

Applied to all print timestamp call sites in badge list (first/last print visible
row + debug row) and to created_on/updated_on in the debug row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:56:46 -04:00
Scott Idem
b8ceed69d0 fix(badges): default sort puts unprinted badges first (print_count ASC, then name ASC)
Previously the default sort was name-only, mixing printed and unprinted badges
together. Staff at registration need unprinted badges at the top so they can
immediately see who still needs a badge without filtering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:47:40 -04:00
Scott Idem
71aacb6346 fix(badges): sync events_slct.event_id from URL param on direct navigation
The badges page relied on events_slct.event_id being set by ae_acct.slct.event_id
(populated when the user clicks an event in the events list). Direct URL navigation
— bookmark, shared link, browser history — skipped that code path, leaving
events_slct.event_id null. Any call using that value (upload form, create form)
would fire with an empty event_id and get a 404.

Reads page.params.event_id on mount, same pattern used by the launcher and
session pages, so all entry paths are reliable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:18:05 -04:00
Scott Idem
04f3b82d59 fix(stores): merge defaults on PersistedState deserialize so new fields get defaults
runed PersistedState hydrates raw stored JSON without merging defaults, so any
field added after a user's first session is undefined rather than its default value.
Apply a shallow spread (defaults → stored) in the custom serializer for badges,
leads, and pres_mgmt stores so new fields always fall back to defaults for
existing sessions — without disrupting any previously saved preferences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:47:25 -04:00
Scott Idem
cc04411d23 feat(badges): goto() after print with per-device fallback toggle
Switch post-print navigation from window.location.href to goto() for
faster SvelteKit client-side transition back to Badge Search (no full
reload). A nav_to_badges() helper in print controls branches on
badges_loc.print_nav_use_goto (default true).

Badges Config page gains a "Local Device Settings" section with a
checkbox to disable goto() and fall back to hard reload — stored in
localStorage per browser, not synced to the event. Useful as an
on-site escape hatch without a code deploy.

Also fixes the Templates button on the config page: adds the standard
border border-surface-300-700 so it looks like a button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:28:33 -04:00
Scott Idem
55f3e3a5a4 feat(badges): add Hole Marker Inset control to badge template form
Exposes punch_holes.inset_x_mm in the badge template config UI under
the Punch-Out Hole Markers section. Visible whenever at least one slot
is enabled. Number input (0–8, step 0.5) with a Reset button that
clears back to the 2mm default. Saved to cfg_json on submit; parsed
back on load; omitted from the payload when left at default (null).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:13:07 -04:00
Scott Idem
955d28d9c5 fix(badges): shrink punch-hole markers horizontally, add inset_x_mm config
Default horizontal inset increased from 1mm to 2mm per side (width shrinks
by 4mm total) so markers stay inside the physical hole slot on all printers
and badge stock with minor registration variance. Vertical stays at 1mm.

The inset is now driven by punch_holes_inset_x (template_cfg.punch_holes
.inset_x_mm, default 2) so it can be tuned per template without a code
change. Added inset_x_mm field to AeBadgeTemplateCfg type with doc comment.

All 9 slot marker divs (6 rainbow + 3 solid) updated via replace_all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:07:59 -04:00
Scott Idem
7c5cf53106 feat(badges): extend search result limit to 500 for trusted staff
Trusted users can now step up to 500 results (300 → 400 → 500) via the
existing ± stepper. Manager tier continues to 2500 as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:52:19 -04:00
Scott Idem
24b52b8027 style(badges): reports — consistent buttons, dark mode, 500-row cap
- Report selector now uses a tonal-primary container matching the pres_mgmt
  reports pattern, making Long Names / Print Throughput clearly clickable
- All hardcoded gray-* colors replaced with surface-*-* tokens and
  dark: variants for proper light/dark mode support
- Control buttons (field selector, threshold, window size) use btn-sm with
  visible borders matching the rest of the events module
- Long Names table uses surface tokens on rows, header, borders
- Print Throughput bar rows use surface tokens; expanded badge chips link
  to /print instead of /review
- Long Names caps display at 500 rows with a warning note when hit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:51:31 -04:00
Scott Idem
6b3fb36926 fix(badges): long names report edit button links to print page with icon
Changed href from /review to /print, added UserRoundPen icon, and
extended the title to include badge ID and name for hover context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:43:23 -04:00
Scott Idem
5cad150b0a fix(badges): move Reports link to trusted+edit toolbar, add reprint count
Reports link moved out of the enable_mass_print gate into the main
trusted+edit-mode toolbar, to the right of Templates — visible whenever
staff are in edit mode regardless of mass-print config.

Reports page header now shows reprint count in parentheses when > 0,
e.g. "142 badges · 98 printed (7 reprints)".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:38:25 -04:00
Scott Idem
8a41f02f0d feat(badges): add Long Names and Print Throughput reports
Two IDB-backed reports under /badges/reports:
- Long Names: filter badges by given/family/full name length (threshold
  adjustable), colour-coded by severity, links to review page
- Print Throughput: bucket print_last_datetime into 5/15/30/60-min
  windows with a horizontal bar chart and expandable badge name list

Also adds a "Badge Reports" nav link on the badges main page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:32:53 -04:00
Scott Idem
88ab5b27d4 fix(badges): skip PATCH and print immediately when offline or API unreachable
When navigator.onLine is false or the account is ghost (API unreachable),
bypass the 20-second API timeout entirely and fire window.print() at once.
The existing error state ("Printed — count NOT saved") already covers this
case. Staff can correct the print count manually after connectivity returns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:19:27 -04:00
Scott Idem
b623557795 feat(layout): collapsible offline/API-error banner with improved messages
Replace the static full-width offline banner with a two-state toggle:
expanded banner (default) with a collapse button, and a small chip in
the top-right corner when collapsed. Adds a subtitle line explaining
what still works offline vs. what requires network. Changes "No API"
chip label to "API Error" and "Offline" title to "Device Offline".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:19:22 -04:00
Scott Idem
7d2b30b7ce fix(badges): open badge review access when no passcode is set
Badges without person_passcode are now viewable by anyone with the URL —
open access is granted on badge load. Previously this was explicitly
denied. The passcode entry form is only shown when the badge actually
has a passcode configured. Auto-validate effect expanded to cover the
no-passcode case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 18:35:47 -04:00
Scott Idem
c9b0acfa06 feat(badges): two-step confirm before sending email review link
Replace the single alert() call with an inline confirmation row per badge.
Clicking "Email Link" shows "Send / Cancel" in place so accidental sends
are avoided. Only one badge can be in confirm state at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 18:35:40 -04:00
Scott Idem
29a24812f4 fix(badges): perceptually uniform rainbow via OKLCH + @property
Replaces filter:hue-rotate() with CSS @property --ph-hue/--ph-lit animated
as numbers, applied as oklch() colors on SVG rect/line children. OKLCH keeps
perceptual lightness constant across hue rotation — no more brown/dark-blue
variance between slots. Pulse mode animates --ph-lit 0.42→0.78 for breathing.
Adds slow_pulse cfg_json flag + form checkbox. @property inherits:true lets
the animated value cascade from the div to its SVG children without per-child
animation declarations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:47:43 -04:00
Scott Idem
70fda25c95 feat(badges): slow pulse mode for rainbow punch hole markers
Adds punch_holes.slow_pulse cfg_json flag. When enabled, replaces the fast
2.5s linear hue-rotate with a 6s ease-in-out breathing animation that dims
(0.55 brightness) to bright (1.30) while shifting 180° of hue and back.
Same 120° phase offsets apply (2s apart). Form shows a Slow Pulse checkbox
below the slot cards whenever at least one slot has rainbow enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:42:23 -04:00
Scott Idem
8f815b7033 fix(badges): tri-phase rainbow cycling for punch hole markers
Each slot gets a fixed animation-delay (left: 0s, right: -0.833s, center: -1.667s)
so they are 120° apart in the hue cycle — same speed, different start points.
Replaces the shared-wrapper approach (all same phase) with per-slot CSS classes
that encode the phase offset, giving a proper tri-phase RGB cycling effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:35:31 -04:00
Scott Idem
ba4a0dc828 feat(badges): static RGB gradient for punch holes on print, cycling on screen
Each rainbow-enabled slot now has a companion print-only div with an inline
SVG linearGradient (R→Y→G→C→B→M spectrum). On screen: the animated
hue-rotate div cycles. On print: CSS hides the animated div and shows the
static gradient instead. X lines print in semi-transparent black over the
gradient fill for visibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:28:56 -04:00
Scott Idem
35c1324824 feat(badges): rainbow animation option for punch-out hole markers
Adds left/right/center_rainbow to punch_holes cfg_json. When enabled,
applies a CSS hue-rotate animation (2.5s loop) to the marker div using
a saturated red base color so the full visible spectrum appears. Template
form shows a Rainbow checkbox per slot; hides color pickers when active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:23:37 -04:00
Scott Idem
1a53a20995 feat(badges): per-side header padding configurable via template cfg_json
Adds header_padding_top/right/left alongside existing header_padding_bottom.
Removes hardcoded p-2 class — all four sides now set via inline style with
0.5rem default. Template form shows a 2×2 padding grid in Header & Branding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:17:04 -04:00
Scott Idem
04c2042060 feat(badges): per-slot fg/bg colors for punch-out hole markers
Adds left_fg/left_bg, right_fg/right_bg, center_fg/center_bg to punch_holes
cfg_json, plus shared fg/bg fallback. Template form shows color pickers per
slot (only when slot is enabled). Defaults: #777777 stroke, rgba white fill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:01:03 -04:00
Scott Idem
4831f4b81b fix(badges): punch hole markers — z-index above header + 1mm safety inset
z-index: 10 ensures markers always render above the header image regardless
of DOM order. Inset 1mm on all sides from physical hole boundary to account
for printer registration variance (3mm-tall slot has no margin for error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:56:29 -04:00
Scott Idem
7bf76bf766 feat(badges): configurable punch-out hole markers for badge clip slots
Adds cfg_json.punch_holes.{left,right,center} to mark pre-perforated badge
clip slots with X overlays. Slots are 5/8in x 1/8in, 1/4in from top,
3/8in from left/right edges. Markers print on the badge so attendees know
where to push out the perforations. Template form exposes checkboxes in
Header & Branding. Documented in MODULE__AE_Events_Badge_Templates.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:52:59 -04:00
Scott Idem
3ae5b30c37 docs(badges): update badges and badge template documentation
MODULE__AE_Events_Badges: update Search & Filter section — visibility
select (3 options, manager-gated), result limit stepper table, badge
type filter hardcoding noted as known gap.

MODULE__AE_Events_Badge_Templates: add full cfg_json reference section
covering all keys (visibility, QR, alignment, header image, appearance,
fit_heights, controls_cfg). Update TODO — duplex/form/header cfg done;
badge_type_list→search wired as open item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:46:59 -04:00
Scott Idem
b04202ecec feat(badges): configurable header bottom border and padding per template
Replaces hardcoded border-bottom/padding-bottom on badge_header div with
cfg_json fields: header_border_color, header_border_width, header_padding_bottom.
Empty color = no border. Template form exposes all three in Header & Branding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:40:18 -04:00
Scott Idem
84c4a2aa43 fix(badges): correct templates link URL — /events/[id]/templates not /badges/templates
Route group (badges) is parenthetical and doesn't contribute a URL segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:05:08 -04:00
Scott Idem
399f98ce8e feat(badges): add Templates link on badge search page for manager access
Shows a Templates button (manager+ edit mode only) before Create Badge,
linking directly to the badge templates management page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:03:11 -04:00
Scott Idem
f5ccd2e3cf feat(badges): configurable header image vertical offset per template
Adds cfg_json.header_margin_top to BadgeTemplateCfg. Badge view replaces
hardcoded mt-8 (2rem) with this value; falls back to 2rem when unset.
Template form exposes the field in the Header & Branding section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:00:21 -04:00
Scott Idem
94a3cb0644 This is just a version bump because things overall seem to be in a very good state. 2026-06-04 18:40:35 -04:00
Scott Idem
9d904446d4 feat(badges): search filter polish and result limit stepper
- Replace show_hidden checkbox with visibility_filter select (Default /
  Show Hidden / Show Disabled+Hidden) — collapses two orphaned boolean
  fields (show_hidden, show_not_enabled) into one purpose-built value;
  wires disabled-badge filter through to both IDB and API paths
- Add max-results stepper (edit mode only): steps of 25 up to 250,
  steps of 100 up to 2550; tier-capped (trusted=250, manager=2550);
  stepper uses pure reactivity — no handle_search_trigger() call needed
- Fix fallback liveQuery (SCENARIO 2): was hardcoded .limit(50);
  now reads qry_result_limit in outer $derived.by so Svelte tracks it
  and stepper updates the no-text browse list immediately
- Fix Search button disabled state: replace pointer-events-none +
  class:opacity-50 with HTML disabled attribute + disabled:cursor-not-allowed
  so hover cursor reflects disabled state correctly
- Global placeholder fix (app.css): add italic + opacity-0.6 rule for
  .input/.textarea ::placeholder in light mode; add italic to dark rule —
  prevents placeholder text from reading as typed content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 18:33:59 -04:00
Scott Idem
b45a27481a Forgot to switch this back to not equals for the last printed timestamp. 2026-06-04 17:08:20 -04:00
Scott Idem
26ab5dda75 fix(service-worker): exclude /fonts/ from SW precache
667 font files (~134MB) were being passed to cache.addAll() on every SW
install. cache.addAll() is atomic — a single failed request aborts the
entire install. Browser Cache Storage quota is typically 50-100MB on
mobile, so the SW has likely been silently failing to install on most
mobile devices, negating all caching and the skipWaiting fix.

Only 3 font files are referenced by the browser (app.css). The rest are
badge-print fonts for server/Electron use and do not need to be precached.
The browser's normal HTTP cache handles them when the print page is visited.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:00:22 -04:00
Scott Idem
0511d9591f docs: document service worker fix, events sort encoding, and slct account_id pattern
BOOTSTRAP__AI_Agent_Quickstart.md:
- Mistake #15 addendum: events/session modules use legacy tmp_sort_1 encoding
  (priority=true→'1'), not build_tmp_sort — requires descending sort until migrated
- Mistake #16 (new): service worker without skipWaiting+clients.claim silently serves
  stale code to long-lived tabs; explains the "can't reproduce in dev" pattern that
  likely caused the IDAA recovery meetings issue for months

CLIENT__IDAA_and_customized_mods.md:
- New "Sort Encoding" section: table of legacy vs build_tmp_sort modules with
  correct comparator direction for each
- New "Search Trigger" section: explains why $slct.account_id not $ae_loc.account_id

TODO__Agents.md:
- IDB Sort: added ae_events__event migration task + legacy encoding warning
- DevOps: marked service worker fix complete; replaced nginx caching item with
  proxy buffer tuning task

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:39:18 -04:00
Scott Idem
59fc7cabc6 fix(service-worker): add skipWaiting + clients.claim for immediate activation
Without these two calls, a new service worker installs in the background but
sits in 'waiting' state until every tab running the old version is closed.
Users who leave idaa.org open all day (common for IDAA members in the iframe)
would run the old JS bundle for hours or days after a fix is deployed, with no
indication that an update exists.

skipWaiting() — new SW activates immediately after install instead of waiting
clients.claim() — new SW takes control of all open tabs right away

This is the most likely root cause of "can't reproduce in testing but users
keep seeing the error": developers refresh/close tabs constantly, end users
don't. The old broken code kept running in their long-lived browser sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:35:34 -04:00
Scott Idem
41b352bc0a Minor style change 2026-06-03 17:57:15 -04:00
Scott Idem
a4fed750fa fix(idaa-recovery-meetings): fix priority sort, stale account_id gate, and cache clear
Three fixes for the IDAA Recovery Meetings load/display issues:

1. Sort direction: events use legacy encoding (priority ? 1 : 0), not build_tmp_sort.
   priority=true→'1' requires DESCENDING sort to put priority items first. The prior
   commit (ee79e33a2) incorrectly applied the build_tmp_sort-compatible ASC comparator
   to the events module, which does not use that encoding. Reverted in +page.svelte
   (both fast-path and API-results sort) and ae_idaa_comp__event_obj_li_wrapper.svelte.

2. Stale account_id gate: search $effect and handle_search_refresh now read
   $slct.account_id (set only by the bootstrap Sync Effect, reliable) instead of
   $ae_loc.account_id (persisted localStorage, may be stale from a prior session).
   Follows the mistake #14 pattern from BOOTSTRAP__AI_Agent_Quickstart.md.

3. Clear Cache & Reload: now enumerates and deletes ALL IDB databases via
   indexedDB.databases() (not just db_events.event), clears all localStorage and
   sessionStorage (not just two keys), and preserves the iframe reload URL for
   Novi re-authentication — matching the Full Reset pattern in e_app_help_tech.svelte.

4. IDB version bump: events.event → v3 (precautionary; flushes any stale cached
   event records on next user load in case prior deploys missed a bump).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:54:05 -04:00
Scott Idem
74e0f752a6 More fine tuning of the look and feel. 2026-06-02 20:14:08 -04:00
Scott Idem
be3e56eece fix(badges): cap controls panel width and center badge+controls pair
- Controls column: max-w-sm (384px) prevents flex-1 from consuming all
  remaining horizontal space on wide screens
- Row wrapper: justify-center + items-center centers the badge+controls
  pair in the available width (equal margins on both sides)
- Mobile: full-width stacked layout unchanged; badge centered via items-center

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:44:16 -04:00
Scott Idem
e2d3c5a822 refactor(badges): replace fixed controls overlay with in-flow flex layout
- Print page: controls panel moves from position:fixed right overlay to an
  in-flow flex column next to the badge. Sticky on lg+ screens; stacks below
  badge on tablet/phone. Eliminates pr-64/pr-80 padding hacks on header and
  badge area. Print behavior unchanged — .event_badge_wrapper uses position:fixed
  relative to @page, so the surrounding layout structure is irrelevant.
- Remove is_editing (was only used to toggle fixed panel width; no longer needed).
- Debug JSON block removed from ae_comp__badge_obj_view (visual render component
  should not contain admin tooling) and moved to controls Staff section.
- Debug JSON uses whitespace-pre + overflow-x-auto so long lines scroll
  horizontally instead of wrapping into hundreds of short lines. Collapsible
  via native <details>/<summary> with no JS state required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:40:26 -04:00
Scott Idem
1b6aeb5b02 fix(badges): polish print page chrome; fix header overlap with controls panel
- Add pr-64/pr-80 + transition to page <header> so Review/Email actions
  stay clear of the fixed right controls panel (same as badge_render_area)
- Remove redundant Re-print button from header — controls panel Test button
  covers the same use case; having both caused clipping and confusion
- Fix datetime formatting: toLocaleString → ae_util.iso_datetime_formatter
  (date_full_no_year + time_12_long) for consistent display with badge list
- Unify loading state to p-16/text-xl/mb-2 matching badge list style
- Polish Badge Not Found layout; swap button weights (Back = primary action)
- Add left-edge shadow to controls panel for better visual separation
- Badge view debug section: gate to administrator_access, add object ID
  labels, add hover:max-h-64 expand and transition on pre blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:11:37 -04:00
Scott Idem
04018a27ed Fine tuning the styles 2026-06-02 18:14:55 -04:00
Scott Idem
4292aebc56 Fine tuning the layout of things. 2026-06-02 17:47:27 -04:00
Scott Idem
3466d6552c feat(badges): show print status strip for trusted staff on printed badges
Adds a compact print info row below each printed badge for trusted users
(not in edit mode — debug row already covers that):
  Printed 2×  ·  First: June 9 9:14 AM  ·  Last: June 9 11:32 AM

Gives staff quick at-a-glance confirmation that a badge was printed and when,
without needing to enter edit mode.

Also fixes a logic bug in the attendee "Checked in" card: the "last print"
line was comparing == (same datetime) instead of !== (different datetime),
so it only appeared when first = last (single print) — backwards. Fixed to
show only when multiple prints have occurred.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:27:40 -04:00
Scott Idem
b7969bc46e Making subtle changes based on actual usage 2026-06-02 17:05:34 -04:00
Scott Idem
99b8eb0b5e fix(badges): focus search input when Search is triggered with too-short query
Pressing the Search button or Enter with fewer than the required min chars
now focuses the input field instead of silently doing nothing, so the user
gets clear feedback about where to type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:55:38 -04:00
Scott Idem
9e361eae9b fix(badges): improve empty-state search hint to reflect actual search fields
Was: "Enter your name above to find your badge."
Now: "Search by name, email, or organization above to find your badge."

Mirrors the actual fast-path fields (given/family name, email, default_qry_str)
and the search input placeholder text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:52:35 -04:00
Scott Idem
e735d0c213 refactor(badges): remove redundant loading gate; fix BarChart2 deprecation
The +page.svelte {#if search_status=loading && ids.length===0} gate is now
redundant — ae_comp__badge_obj_li handles all states internally (Searching...,
Enter your name, No results). Removing it also fixes a UX regression where
trusted-user IDB fallback results would be hidden by the gate whenever a new
API search fired. Component is now always mounted and manages its own state.

Also replaces deprecated BarChart2 with ChartColumnBig (lucide rename).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:48:44 -04:00
Scott Idem
d05cc63459 fix(badges): remove transition from initial loader to fix double-DOM bounce
transition:fade on the initial spinner caused Svelte to keep the outgoing
element in the DOM for the full 200ms outro while the incoming badge list
was already rendered — both were live simultaneously, colliding on height
and producing the visible bounce. Initial cold-start load doesn't need a
transition; instant swap is fine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:44:35 -04:00
Scott Idem
ac17417f3c fix(badges): fix search-result layout shift + unify empty/loading states
Scrollbar shift:
- Add [scrollbar-gutter:stable] to #ae_main_content in events layout so a
  scrollbar appearing on first results load no longer reflows the centered
  search form (was shifting ~8px left on Linux)

Empty/loading state consistency:
- Move search_status prop into ae_comp__badge_obj_li so it can swap its own
  empty state: spinner + "Searching..." while a search is in progress,
  UserSearch icon + prompt text otherwise
- Unify p-16 / size-3em / mb-2 / text-xl across all three states (initial
  load, searching, no results) so height never jumps between transitions
- Pass search_status from +page.svelte to the component

Transitions:
- transition:fade on initial-load spinner div
- transition:slide on Create/Upload badge button row (appears with edit mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:38:30 -04:00
Scott Idem
3773758eb5 feat(badges): smooth transitions + polish for badge search UI
- Adds fade/slide transitions throughout the search form: form mount/unmount,
  filter row, QR scan button, QR scanner panel, Show Hidden, Remote First labels
- Min-chars hint switches from class:invisible to opacity-0/opacity-50 +
  transition-opacity so it fades instead of snapping
- Clear button switches from class:hidden to opacity-0 + pointer-events-none
  + transition-all so it fades without causing layout shifts
- "Start Here" button gets transition-opacity for smooth dim on first keystroke
- Replaces FileSearch with UserSearch icon in the empty state
- Adds w-full to empty state div to prevent subtle page-width shift between
  no-results and results states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:25:31 -04:00
Scott Idem
60bdd2fdba feat(badges): public_access kiosk mode + manager access improvements
- Public (attendee) kiosk: unprinted badges link to /review; printed
  badges show green "Checked in · Nx · First/Last" row (non-clickable)
- Public attendees no longer see Print button (staff-only action)
- Printed badges sort to end of list for public non-trusted users
- Manager access: Print (reprint) and Email Link buttons always visible
  without requiring Edit Mode; main row behavior unchanged
- Empty state wording: context-aware — "Enter your name above to find
  your badge" for public users with no query vs "No badges found" after
  an actual search
- Docs: Epson C3500 fanfold section filled in (was empty placeholder);
  style_href/duplex implementation status corrected in badge templates
  doc; Axonius C3500 layout TODO marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:15:08 -04:00
Scott Idem
72c8f9b502 docs(todo): archive June snapshot and streamline active task list 2026-06-02 15:00:24 -04:00
Scott Idem
1de87b6c5f chore(cleanup): add journal AI shortcut and align posts tmp_sort 2026-06-02 14:43:16 -04:00
Scott Idem
84a9d0fffc chore(core): remove retired site_domain helper and update docs 2026-06-02 14:34:19 -04:00
Scott Idem
87084f0f71 chore: migrate lucide package and close quick TODO cleanups 2026-06-02 14:19:12 -04:00
Scott Idem
de048a084b chore: remove axios + deprecated electron_native.js; track lucide-svelte migration
- Uninstall axios — only consumer was electron_native.js (legacy V2 file)
- Delete electron_native.js — deprecated 2026-02-10, no active imports; replaced
  by aether_app_native_electron repo + electron_relay.ts contextBridge
- Add TODO: migrate remaining lucide-svelte → @lucide/svelte (5 files) then uninstall

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:04:28 -04:00
Scott Idem
ee79e33a2a fix(idb-sort): correct tmp_sort_* comparator direction in journals, IDAA recovery meetings, and BB post comments
build_tmp_sort() encodes priority=true as '0' for ascending sort. JS comparators
were using b.localeCompare(a) (descending), inverting the encoding so priority=false
items sorted first. Fixed to a.localeCompare(b) in ae_journals_search_helpers.ts (3
sites in recovery_meetings +page.svelte and wrapper component).

Also fixes a Dexie anti-pattern in bb/[post_id]: .reverse() before .sortBy() is a
no-op in Dexie; moved array .reverse() to after the await.

Documents the encoding rule and legacy inverted-encoding modules in
GUIDE__SvelteKit2_Svelte5_DexieJS.md and adds mistake #15 to BOOTSTRAP quickstart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:50:15 -04:00
Scott Idem
a5243fa820 docs: document bootstrap account_id race condition and liveQuery stale-record pattern
Adds entry #14 to BOOTSTRAP__AI_Agent_Quickstart.md (section 7 "Mistakes
Agents Have Made") and a new "Bootstrap Race" subsection to
GUIDE__SvelteKit2_Svelte5_DexieJS.md ("Common Gotchas"), capturing the
fix from 5fce14980: gate account-scoped liveQuery triggers on
$slct.account_id (non-persisted), not $ae_loc.account_id (persisted,
potentially stale), and treat IDB records from a different non-null
account as a cache miss. Also fixes five pre-existing MD049 emphasis
style warnings (asterisk → underscore) in the Dexie guide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:39:11 -04:00
Scott Idem
5fce149808 fix(element_data_store): fix stale account_id showing wrong record on fresh load
Two guards added to the trigger effect:

1. Gate on $slct.account_id being set — prevents the fetch from firing before
   the bootstrap Sync Effect has propagated the real account_id. Without this,
   get_object's localStorage scavenge read a stale account_id (e.g. 1 from a
   prior dev/demo session) and the API returned the wrong account's record.

2. Stale-account detection — if liveQuery returns an IDB row with a non-null
   account_id that doesn't match the current account, treat it as a cache miss
   and fetch the correct record. Null (global/default) rows are still accepted.

Root cause: ae_loc is a persisted store that hydrates from localStorage before
the bootstrap Sync Effect runs. Old account-specific IDB rows scored highest in
the liveQuery sort, suppressing the trigger and leaving the wrong record visible
until the next full page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:04:59 -04:00
Scott Idem
a74effa6ff feat(badges): add Cvent Splash XLSX import mode; fix server-side upload timeout
- Add 'Cvent Splash XLSX (registrant export)' upload mode hitting the new
  /event/{id}/badge/import/splash_xlsx endpoint
- Admin controls: begin_at/end_at/return_detail (shared with Zoom mode) +
  import_status_filter (splash only, default 'Attending')
- File picker accept attribute switches between .csv and .xlsx per mode
- Set timeout=300000 and retry_count=1 on both server-side upload paths to
  prevent false 'no response' failures on slow imports; upsert-by-email on
  the backend makes retries safe but a single attempt is sufficient
- Replace misleading 0/0 progress bar with an indeterminate progress bar
  during server-side processing; real counter kept for client-side CSV mode
- Show 'Processing on server…' message once upload completes and server work begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:37:48 -04:00
Scott Idem
33d48e7e78 Doc updates and project (sort of) temp files 2026-06-02 09:25:03 -04:00
Scott Idem
65e48c764e docs: document build_tmp_sort pattern and liveQuery filter dependency capture
Added two new sections to GUIDE__SvelteKit2_Svelte5_DexieJS.md:
- IDB Sort: build_tmp_sort Pattern — sort chain, priority inversion
  encoding, anti-.reverse() warning, modules using it
- $derived.by Dependency Capture — SCENARIO 2 filter pattern and
  API snapshot consistency fix

Updated TODO__Agents.md:
- Added anti-.reverse() warning and guide pointer to build_tmp_sort entry
- Added Sessions hide/show toggle section with both fixes marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:24:10 -04:00
Scott Idem
f6c950abdf fix(pres_mgmt): fix hidden sessions toggle not showing hidden sessions
Three related fixes for the hide/show toggle in Pres Mgmt:

1. ae_events__event_session.ts: remove redundant search_query.and hide
   filter and instead pass `hidden` to api.search_ae_obj as a URL param.
   The backend StatusFilterParams defaults to hidden='not_hidden', so
   without this the API always filtered to hide=0 regardless of intent.

2. pres_mgmt/+page.svelte (SCENARIO 2): capture qry_hidden as a
   $derived.by dependency so the liveQuery instance is recreated on
   toggle — prevents hidden sessions briefly appearing before the
   debounce fires (blink fix).

3. pres_mgmt/+page.svelte (API call): use params.qry_hidden snapshot
   instead of the live store to prevent race if user toggles during a
   pending search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:24:04 -04:00
Scott Idem
3d6f9035c8 docs(todo): track build_tmp_sort rollout progress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:59:30 -04:00
Scott Idem
535efd9c4b fix(pres_mgmt): add group as leading sort prefix for event_presentation
Group should partition before priority in the sort chain, consistent with
how all other AE objects are sorted (group → priority → sort → ...).
Was accidentally omitted when switching to build_tmp_sort.

Full order: group → priority DESC → sort ASC → start_datetime ASC → code ASC → name ASC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:48:48 -04:00
Scott Idem
4a39ca1468 refactor(sort): introduce build_tmp_sort utility; apply to event_presentation and journals
Creates src/lib/ae_core/core__idb_sort.ts with build_tmp_sort() — a shared
helper for computing tmp_sort_1/2/3 fields stored in Dexie. Fixes two bugs
present in all generic _process_generic_props implementations:
  - priority encoded as 0/1 ASC (true sorted last); now inverted: true→'0'
  - sort stored as unpadded string ("10" < "2"); now 8-char zero-padded

Applied to:
  - ae_events__event_presentation: replaces inline specific_processor code
  - ae_journals__journal + ae_journals__journal_entry: replaces manual formulas;
    journal liveQueries (.reverse().sortBy()) updated to plain .sortBy() since
    the inverted encoding handles direction without needing Dexie's .reverse()

Other modules (sessions, presenters, locations, posts, core) left unchanged
until their sort behavior is reviewed separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:38:25 -04:00
Scott Idem
182a066d38 fix(pres_mgmt): use tmp_sort_2 for presentation sort in Pres Mgmt and Launcher
Compute presentation-specific tmp_sort_1/tmp_sort_2 in specific_processor,
overriding the generic values from _process_generic_props which had two bugs:
- priority encoded as 0/1 ASC (backwards — true should sort first)
- sort stored as unpadded string ("10" < "2" lexicographically)
- start_datetime and code not included (presentation-specific fields)

New encoding: priority(inv)_sort(8-padded)_start_datetime_code[_name]
Both liveQueries (Pres Mgmt session page, Launcher session view) now use
.sortBy('tmp_sort_2') — cleaner and uses the indexed field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:20:50 -04:00
Scott Idem
35fed53e2a fix(launcher): sort presentations by priority > sort > start_datetime > code > name
Replaced single-field sortBy() (poster→name, oral→start_datetime) with
toArray() + JS comparator matching the same sort chain as Pres Mgmt.
Removes the sort_by branch since the comparator handles both session types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:11:14 -04:00
Scott Idem
322abc2691 fix(pres_mgmt): sort presentations by priority > sort > start_datetime > code > name
Replaced single-field sortBy('name') with toArray() + JS comparator to
implement the full desired sort chain. Dexie's sortBy() only supports a
single indexed field, so multi-field ordering requires a JS sort pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:38:13 -04:00
Scott Idem
cfaf687717 fix(events): event location file load on mount + launcher pruning scope fix + remove legacy launcher btn
- Location page now calls load_ae_obj_li__event_file on mount so files
  appear immediately without requiring a manual Refresh press.
- _load_event_location_sub_data (Launcher 60s sync) now uses hidden='all'
  with default limit (100) instead of hidden='not_hidden'/limit=25, which
  was pruning valid Dexie records when Pres Mgmt and Launcher were both
  open on the same location simultaneously.
- Removed the legacy launcher button (Send icon, /event/ path) from the
  Locations list; removed unused Send icon import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:41:32 -04:00
Scott Idem
e7620a1c06 fix(events): replace db_events.file.clear() with targeted reload in refresh button
Same nuclear cache-clearing bug as the upload component fix. Clicking "Refresh
files" for one Location was wiping every event_file record from Dexie, leaving
all other Locations and Presenters with no files until their own background
syncs fired.

Now does a targeted load for the specific object only — same pattern as the
upload component commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:07:00 -04:00
Scott Idem
e4e2174c97 fix(upload): replace db_events.file.clear() with targeted per-object reload
db_events.file.clear() after every upload was nuking the entire Dexie file
table. Every liveQuery watching any file list (Launcher, other locations,
sessions, presenters) would immediately show 0 results. Only the uploaded-to
object's files were reloaded; all others remained empty until their own
background syncs fired — intermittent disappearance that depended on timing.

Fix: targeted load_ae_obj_li__event_file for only the current link_to_id,
which uses the SWR pattern (returns cache + background refresh that includes
the newly created file). Other objects' file caches are untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:33:12 -04:00
Scott Idem
d3bf314c62 fix(launcher): refresh file lists periodically to prune deleted/hidden files
The Launcher's background sync never called load_ae_obj_li__event_file for
presenter/session files. That function contains stale-record pruning that
removes deleted or hidden files from Dexie; without it, the Launcher's IDB
retained stale file records indefinitely until manually cleared.

Changes:
- refresh_presenter_data: add inc_file_li=true so presenter files are pruned
  every 120s via the existing presenter loop
- refresh_current_session_files(): new function that fetches/prunes session-
  level file lists for the selected session
- timer__file_list: 60s interval for refresh_current_session_files
- $effect on event_session_id: fires refresh_current_session_files immediately
  on session switch (no wait for next timer tick)

Propagation time: deleted/hidden files visible on remote Launchers within
~60s (session files) or ~120s (presenter files) automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:12:10 -04:00
Scott Idem
d32355a1a2 docs(launcher): add cfg menu inventory and v3.1 design docs
MODULE__AE_Events_Launcher_Config_Menu.md — v3.0 inventory of the
3-tab drawer layout (now superseded but kept as reference baseline).
MODULE__AE_Events_Launcher_Config_Menu_new.md — v3.1 unified design
spec that drove the sidebar tab migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:17:07 -04:00
Scott Idem
213eabd8c1 feat(launcher): migrate cfg menu to 7-tab sidebar layout (v3.1)
Replaces the 3-tab horizontal bar (Setup / Device / Dev) with a vertical
sidebar navigation matching the v3.1 design spec. New tab structure:

  General       — App Modes, Screen Saver (operator-facing)
  Connectivity  — Remote Controller & WebSocket
  Sync & Health — Sync Timers, System Health
  Native OS     — OS controls (native or edit_mode preview)
  Wallpaper     — Desktop wallpaper settings
  Advanced      — Launch Timing, Updates (edit_mode only)
  Maintenance   — Local Resets, Debug Panel (edit_mode only)

Layout changes:
- Sidebar nav (w-48) + scrollable main content area replace inline tab bar
- Tab header shows label + description subtitle
- Technical Mode toggle is now a labeled button (not hidden icon)
- Footer shows Account/Device context; Reload moved to header
- {#key active_tab} wrapper ensures clean component remount on tab switch
- Remove unused icons (SlidersHorizontal, HeartPulse, Timer, CloudDownload)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:14:52 -04:00
Scott Idem
872291b0a0 fix(launcher): replace Flowbite Modal with custom overlay for cfg panel
Two problems with the Flowbite <Modal> approach:
1. Built-in dismissable CloseButton rendered with no functional dismiss path
   (no title/form), appearing centered in the panel.
2. size="xl" (max-w-7xl) left no backdrop area on typical laptop screens,
   making outsideclose impossible to trigger.

Replace with a simple custom overlay: full-screen backdrop div that closes
on click, inner panel with stopPropagation. Matches the original Drawer
pattern. close_cfg() writes to store immediately on backdrop click for
reliable persistence independent of effect timing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:04:34 -04:00
Scott Idem
25d17841e4 fix(launcher): fix cfg modal default-open and outside-click persistence
Two bugs in the Launcher Config Modal after the Drawer→Modal migration:
1. Pre-existing persisted configs (missing hide_drawer__cfg field) caused
   !undefined = true, opening the modal on every fresh load. Fixed by adding
   a field-level initialization guard after the full-object guard.
2. $-syntax writes inside untrack() were suppressed by svelte-persisted-store,
   so outside-click closure was never persisted. Fixed by using events_loc.update()
   directly to ensure the write reaches localStorage serialization. Added equality
   guard to effect 1 to prevent spurious modal flicker from whole-store re-fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:36:01 -04:00
Scott Idem
6282fb167f fix(launcher): use $derived.by for session liveQueries to fix stale presentation/presenter data
When switching sessions within the same location, presentations and presenters
were not updating. The root cause: plain $derived(liveQuery(...)) never recreates
the Observable when slct__event_session_id changes, because liveQuery's async
callback runs in Dexie's zone where Svelte tracking is off. Dexie's range-level
change detection then ignores new session data (it arrives under a different
event_session_id index value, outside the originally observed range).

Replaced all four liveQuery declarations with $derived.by(() => { const id = ...;
return liveQuery(...id...); }) — the same pattern already used in +layout.svelte
for location-dependent queries. Svelte tracks the id read in the outer closure
and recreates the Observable on every session change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:55:17 -04:00
Scott Idem
a90572bcb8 fix(idaa): ensure breakout links preserve site access key and uuid
Proactively re-injects 'key' (site access key) and 'uuid' (Novi token)
into 'Open Externally' and 'Copy Link' URLs on the Video Conferences
page. This prevents authentication failures when members open meetings
in a new browser tab after SvelteKit internal navigation has dropped
the bootstrap parameters.

Updated CLIENT__IDAA_and_customized_mods.md to document the requirement
for these keys in breakout URLs.
2026-05-23 11:31:10 -04:00
Scott Idem
194c89f6d1 style(launcher): layout and Tailwind class adjustments
+layout.svelte: add lg:min-h-8/12 and max-h-screen to main content area.
launcher_background_sync.svelte: reposition sync monitor panel (bottom-15,
left-2, z-10 — was bottom-20, left-4, z-9999).
launcher_menu.svelte: reorder Tailwind classes for readability, no change
to applied styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:09:08 -04:00
Scott Idem
469729ce22 revert(help_tech): restore ae_loc preservation in Clear & Reload button
Reverts the change from d1f5d0e2f that removed ae_loc preservation.
The tech help component is used across non-Launcher contexts where
users are authenticated normally and should not be signed out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:06:56 -04:00
Scott Idem
d1f5d0e2fd fix(launcher): clear ae_loc in cache cleanup; align tech help Clear & Reload
menu_launcher_controls: handle_cache_cleanup now removes both ae_events_loc
and ae_loc from localStorage, giving a true clean slate on reload.

e_app_help_tech: Clear & Reload button no longer silently re-saves ae_loc
after clearing — if edit mode wipes localStorage, ae_loc goes with it.
Updated confirm message and title tooltip to say "you will be signed out"
instead of the previous misleading "sign-in will be preserved."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:05:16 -04:00
Scott Idem
9c83567430 feat(launcher): add Clear Cache and Reload Launcher buttons to controls bar
Fills in two new buttons added to menu_launcher_controls.svelte:
- Clear Cache: removes 'ae_events_loc' from localStorage and deletes the
  ae_events_db IndexedDB database, then reloads — clears stale launch state
  without touching downloaded file cache or user prefs (theme/font size).
- Reload Launcher: calls native.window_control({ action: 'reload' }) in
  Electron, falls back to window.location.reload() in browser mode.

Also fixes a stray 'lucide-svelte' import (merged Recycle into '@lucide/svelte')
and separates cache_status from reset_apps_status so button labels stay correct
when multiple actions fire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:02:26 -04:00
Scott Idem
b4d0d82141 fix(launcher): fix VLC stopping 10-15 seconds after open on macOS
Root cause: run_cmd uses exec() which blocks until the child exits. The
direct VLC binary forks its GUI process and exits — exec returns and the
post_script begins. The old post_script polled for VLC focus (up to 10s)
then sent Cmd+F, which fired mid-playback and stopped the video.

Fix 1 — nohup + &: detaches VLC from exec immediately so run_cmd returns
in ~0ms. This decouples the launcher flow from VLC's lifecycle.

Fix 2 — --fullscreen flag: VLC opens fullscreen directly via CLI option.
Eliminates the Cmd+F keystroke that was the proximate cause of the stop.

Fix 3 — > /dev/null 2>&1: silences VLC's verbose logging to prevent
exec's 1MB stdout buffer from overflowing and killing the process.

post_script simplified to a single `tell application "VLC" activate`
to bring VLC to the foreground after the 3s startup delay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:10:37 -04:00
Scott Idem
15bfe6d5d6 feat(launcher): move Reset Apps to always-visible controls bar
Adds a presenter-accessible "Reset Apps" button to menu_launcher_controls
that is always visible (no edit mode required). Kills presentation apps
(PowerPoint, Keynote, Acrobat, VLC, soffice) — critical recovery path for
presenters stuck on stage with a frozen app.

Also: warning colors on All Files / All Sessions when showing non-default
(hidden) content, and state-aware tooltips on the Display Mode toggle that
describe current state and what pressing will do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:02:22 -04:00
Scott Idem
dddf4b6170 feat(launcher): restore Kill Apps button in Native OS config
Kills the standard conference presentation app set between sessions:
Microsoft PowerPoint, Keynote, Adobe Acrobat Reader DC, VLC, soffice.

- Calls native.kill_processes({ process_name_li }) via existing relay
- Process list overridable per device via event_device.other_json.launcher.kill_process_li
- Button lives in Native OS config > System Actions (edit mode only)
- Reuses system_status for feedback — shows which apps are being killed
- Original list recovered from git history of legacy architecture docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:43:42 -04:00
Scott Idem
587b815446 docs(todo): mark composable flow + slide scripts done; add event_file cfg_json backend task
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:38 -04:00
Scott Idem
ca51a82dae feat(launcher): richer tooltip on file download button
Tooltip now shows file size, created/updated timestamps, and open_in_os
setting alongside the existing SHA256 and hosted ID info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:35 -04:00
Scott Idem
a38320c7f5 fix(launcher): monospace font for session list date/time column
Datetime values align cleanly across rows when rendered in font-mono.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:31 -04:00
Scott Idem
c76fb8f2b5 fix(launcher): open_in_os win routing, display override, and onsite ext fix
- open_in_os='win' now routes to Windows launch profiles (pptxwin/pptwin/odpwin/pdfwin)
  via WIN_EXTENSION_MAP in get_launch_profile() — was silently ignored before
- Display override migrated from non-existent cfg_json backend field to localStorage
  ($events_loc.launcher.file_display_overrides) — only visible in edit mode; TODO added
  for proper backend column when event_file gains cfg_json
- Onsite mode WIN extension rename now covers all 4 types (pptx, ppt, odp, pdf)
  instead of only pptx/ppt
- open_in_os button shows LoaderCircle spinner during API call
- Remove cfg_json from properties_to_save (column does not exist on event_file table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:58:27 -04:00
Scott Idem
a26ea8b49c fix(launcher): optimistic update for display override button
Without this, the button depended on the liveQuery round-trip to show
the new state — invisible on stale IDB caches that predate the cfg_json
properties_to_save fix. Now mutates event_file_obj locally on click so
the button reflects the new state immediately, with the background
refresh as confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:18:58 -04:00
Scott Idem
21fad1a698 fix(launcher): restore open_in_os win routing, fix cfg_json in IDB, fix display state
open_in_os = 'win': get_launch_profile() now maps pptx→pptxwin, ppt→pptwin,
odp→odpwin, pdf→pdfwin when open_in_os is 'win', routing to the Windows-variant
launch profiles (Parallels/CrossOver). Was never wired in native mode — feature
was silently lost in the MasterKey→Launcher port.

cfg_json missing from properties_to_save: the per-file display override was
always read as undefined from Dexie because cfg_json was never saved. Added
cfg_json to properties_to_save so display_override and any other cfg fields
persist correctly. NOTE: IDB_CONTENT_VERSIONS for event_file is not yet wired;
existing devices need a manual cache clear to pick up the new field.

Display override button: removed $ae_loc.is_native gate — must be configurable
from any device ahead of the event, not only on the podium Mac.

Display toggle persistence: quick_display_mode now reads from and writes to
$events_loc.launcher.display_mode so the last-set state survives page reloads
instead of always defaulting to 'extend'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:12:12 -04:00
Scott Idem
33e9eeef78 fix(launcher): retry activate in post-script loop to beat macOS focus-stealing
All app post-scripts called activate once before the poll loop, then only
checked `frontmost` passively. When Electron retains focus (common when the
app is spawned via run_cmd), macOS focus-stealing prevention blocks the one-
shot activate and VLC/PowerPoint/etc. never come to front. The poll loop
times out and fires the keystroke at Electron instead.

Fix: move activate inside the repeat loop so it re-fires every 0.5s until
macOS yields focus. Also bumped VLC post_delay_ms 1000→2000 and iterations
15→20 for slow conference machines.

Affected profiles: VLC (mac), PowerPoint (mac), LibreOffice (mac+win),
Acrobat (mac+win).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:51:53 -04:00
Scott Idem
172ea994c7 refactor(launcher): consolidate menu controls and anchor to bottom
- Combine Extend/Mirror into a single toggle button, moved behind edit_mode
- All edit-mode controls (All Files, All Sessions, Display) now share consistent preset-tonal-tertiary styling
- Remove the always-visible display row and its non-native-mode disclaimer
- Wrap Menu_launcher_controls in mt-auto to keep it pinned to the bottom of the sidebar regardless of session count
- Add min-w-20 to file size chip to prevent collapse on narrow sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:48:53 -04:00
Scott Idem
17b549a75c docs/refactor: finalize V3 cleanup and archive badge config project
- Moved PROJECT__AE_Events_Badges_Config_Cleanup.md to archive.
- Updated PROJECT__Use_AE_API_V3_CRUD_upgrade.md with latest audit and migration status.
- Migrated ae_comp__event_presenter_form_agree.svelte to modern V3 update_ae_obj helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:04:07 -04:00
Scott Idem
3de01af1a1 docs: cleanup and archive agent TODO list
Archived completed May 2026 tasks and streamlined the active list to
focus on upcoming events and the final V3 API surgical cleanup.

- Created TODO__Agents__ARCHIVE_2026-05.md with completed items.
- Streamlined TODO__Agents.md for active show support (CMSC, Axonius).
- Added V3 CRUD migration tracking for core site and utility helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:02:25 -04:00
Scott Idem
518a450b91 docs(events): reorganize badges and leads documentation
Standardized documentation structure for Badges and Leads modules into
focused technical references and practical onsite guides.

- Refined MODULE__AE_Events_Badges.md (Core data integrity & sync logic)
- Renamed MODULE__AE_Events_Exhibitor_Leads.md to MODULE__AE_Events_Leads.md
- Renamed MODULE__AE_Events_Badges_Onsite.md to GUIDE__AE_Events_Badges_Onsite.md
- Expanded GUIDE__AE_Events_Onsite_Runbook.md with Badge and Leads sections.
- Maintained all critical business logic, including the 'Override Fields' pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:40:06 -04:00
Scott Idem
cb767ed115 docs(events): reorganize presentation and launcher documentation
Split the monolithic MODULE__AE_Events_PressMgmt_Launcher.md into focused,
granular modules to improve maintainability and onsite utility.

- Created MODULE__AE_Events_Presentation_Management.md (Back-office focus)
- Created MODULE__AE_Events_Launcher.md (Podium display focus)
- Created GUIDE__AE_Events_Onsite_Runbook.md (SRR and onsite workflows)
- Promoted PROJECT__AE_Events_Launcher_Native_integration.md to
  MODULE__AE_Events_Launcher_Native.md (Permanent technical reference)
- Renamed 'Press Mgmt' references to 'Presentation Management' for clarity.
- Removed redundant README.md in launcher route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:36:00 -04:00
Scott Idem
86201f0fc1 feat(launcher): implement force-sync and chronological download priority
Onsite operators can now manually trigger a full pre-sync of all location
materials via a "Force Sync Location" button in the Launcher config.
This ensures podium Macs have all content cached before an event starts.

- Added trigger__force_location_sync to launcher session store.
- Implemented force_location_sync() in background sync engine to perform
  recursive metadata fetches for all sessions in a location.
- Optimized download queue with a 4-tier chronological sort:
  1. Event/Location assets (Global)
  2. Sessions by start time (Earliest first)
  3. Presentations by start time (Sequential order)
  4. File creation date (First-in fairness for on-time uploads)
- Updated module and native integration documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:29:13 -04:00
Scott Idem
60e3fc539e docs(devops): add Nginx caching investigation task for app version pickup issue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:31:51 -04:00
Scott Idem
b3029a4d27 docs: update TODO and add BOOTSTRAP mistake #13 for API retry regression
TODO__Agents.md:
- Added the two additional fixes from the review pass to the PATCH/DELETE
  retry hardening entry: default timeout 60s→20s, and DELETE missing
  ae_auth_error banner on 401/403.

BOOTSTRAP__AI_Agent_Quickstart.md:
- Added mistake #13: breaking the API retry loop by returning errors from
  the TypeError/AbortError block instead of throwing them. Documents the
  Jan 2026 regression (commit a10accfaa), the three retry classes that must
  be preserved, and a quick verification method.
- Filled the gap at item #7 (was missing, causing off-by-one numbering
  from item 8 onward). Items renumbered 8-14 → 7-13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:21:01 -04:00
Scott Idem
ea765d8ad2 fix(api): lower patch/delete timeout to 20s and add delete auth error banner
Two gaps found during review of the recent retry-hardening commits:

1. api_patch_object.ts and api_delete_object.ts still defaulted to 60s
   timeout while GET/POST were lowered to 20s. No callers set an explicit
   timeout, so the default was the only value used. With retry_count=5 and
   the new backoff policy, 60s per attempt = 5+ minutes worst-case wait.
   Lowered to 20s to match GET/POST and keep worst-case under ~2 minutes.

2. api_delete_object.ts had no ae_auth_error import and no session-expired
   banner on 401/403. A stale-session DELETE would silently return false
   with no user feedback. Added browser + ae_auth_error imports and the
   ae_auth_error.set() call matching the pattern in GET/POST/PATCH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:11:32 -04:00
Scott Idem
db5acdd30a docs: align API retry hardening status with implemented helpers 2026-05-21 18:04:06 -04:00
Scott Idem
a000e07647 api: harden delete retry classification and backoff 2026-05-21 17:58:59 -04:00
Scott Idem
7f9368589a api: harden patch retry classification and backoff 2026-05-21 17:53:30 -04:00
Scott Idem
55d3d49595 test: add v3 latency probe and modernize api coverage 2026-05-21 17:48:00 -04:00
Scott Idem
f5cf1ef398 api: separate timeout abort retries from intentional aborts 2026-05-21 15:46:30 -04:00
Scott Idem
d5d552a029 Badge layout fix for Axonius 2026-05-21 15:19:48 -04:00
Scott Idem
689bb326cb fix(api): restore network-error retry and add backoff in get/post_object
The Jan 2026 "offline-first fast-paths" commit (a10accfaa) inadvertently
broke retries for transient network failures (ERR_NETWORK_CHANGED, WiFi
roam events, etc.). The original code's .catch() returned undefined, which
fell through to the `if (!response) throw` path and correctly entered the
retry loop. After a10accfaa, .catch() returned the error as a value, and
the subsequent `instanceof Error` check returned false immediately —
bypassing all retries for the most common failure mode in
hotel/conference environments.

Changes:
- TypeError now throws into the retry loop instead of returning false
- AbortError still returns false immediately (intentional cancel, no retry)
- Per-attempt AbortController: moved inside the loop in both files so each
  retry gets its own independent timeout (previously GET retries had no
  timeout at all after the first attempt's clearTimeout ran)
- clearTimeout() added to catch block so timer is always cancelled on error
- Exponential backoff added: 2s→4s→6s→8s (capped) between attempts;
  rapid retries on a flaky network accomplish nothing without a delay
- Default timeout lowered: 90s → 20s (generous for search/GET but avoids
  the 90s worst-case hang that amplified ERR_NETWORK_CHANGED exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:44:12 -04:00
Scott Idem
e6db2b4d6a fix(idaa): add Clear Cache & Reload escape hatch to recovery meetings server error state
"Try Again" resets auto_retry_count but reuses the same localStorage state — if
ae_loc or ae_idaa_loc holds a stale account_id or api_secret_key, every retry
fails identically and the user is stuck in an infinite error loop.

New button clears ae_loc + ae_idaa_loc from localStorage and db_events.event
from IDB, then reloads via the sessionStorage-preserved UUID URL (same logic as
the IDAA layout's Clear Cache & Reload). Forces a fresh FQDN handshake and
re-derives correct auth state. Guidance text shown so users know to try it when
Try Again keeps failing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 12:30:53 -04:00
Scott Idem
cfc5d237c7 docs(todo): add launcher wallpaper drift hotplug follow-up
Track projector/display unplug-replug wallpaper reset behavior and evaluate loop/check/event-driven strategies.
2026-05-20 19:30:37 -04:00
Scott Idem
a56f520d4e feat(launcher): add quick mirror/extend toggle in left menu
Always visible in launcher menu, disabled outside native mode, with title text and preview-note messaging.
2026-05-20 19:28:46 -04:00
Scott Idem
c0f828ec2c feat: Add platform-specific VLC launch profiles (macOS working; Linux deferred)
- macOS: Uses direct binary path (/Applications/VLC.app/Contents/MacOS/VLC)
  with --no-play-and-exit --play-and-pause flags and AppleScript fullscreen toggle.
  Confirmed working: pause on last frame, stays in window.

- Linux: Uses shell command (vlc --no-play-and-exit --play-and-pause).
  Known issue: not going fullscreen, not pausing on end. Deferred for later
  investigation of flag interpretation vs launcher execution layer.

- Created separate profile constants: VLC_MIRROR_MAC_PROFILE and
  VLC_MIRROR_LINUX_PROFILE in ae_launcher__default_launch_profiles.ts

- Updated TODO__Agents.md with Linux VLC issue for future debugging

Files: ae_launcher__default_launch_profiles.ts, documentation/TODO__Agents.md
2026-05-20 19:09:15 -04:00
Scott Idem
5bb06c4904 feat(display): expose list_display_modes and set_display_mode in relay
Svelte callers can now enumerate all display modes and set resolution/
refresh/HiDPI per display through the native bridge.
2026-05-20 18:16:43 -04:00
Scott Idem
7621e044b4 docs(launcher): update display layout doc — display_control replaces displayplacer as primary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:48:25 -04:00
Scott Idem
76569a872f feat(launcher): add display mode toggle; fix silent display layout failures
- Add Extend/Mirror toggle to Native OS config section (always visible,
  no Technical Mode required). Default: Extend. State updates on success.
- Replace .catch(() => {}) swallowing with console.warn logging on both
  set_display_layout call sites so failures appear in the Electron console
- Remove old edit-mode-only Extend button (replaced by new toggle)
- Update PROJECT doc: displayplacer install note, binary lookup order, GitHub link
- Clean up TODO__Agents.md: resolve stale items, add new low-priority Electron items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:33:33 -04:00
Scott Idem
d9726d062e docs(launcher): sync docs to current code state
set_wallpaper: update signature in PROJECT doc (§5.3 and §7) — actual params
are {path?, url?, url_external?, display?, api_key?, account_id?}, not just {path}.

set_display_layout: clarify in PROJECT doc (§5.3 and §7) — now auto-detects
displays via displayplacer list; configStr is optional manual override, not required.

MODULE profiles table: correct post_delay_ms values (Mac 2000→1000ms,
Win 3000→1500ms) and add polling-loop note explaining the new timing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:16 -04:00
Scott Idem
91f40c4a89 fix(launcher): border-2 on wallpaper chips/buttons for VNC readability
ring-1 uses CSS box-shadow which nearly disappears when the UI is scaled
down through VNC. Switched all preset chips and action buttons to border-2
so they remain clearly distinct at reduced resolution.
preset-tonal-surface + border-2 border-surface-400 for unselected chips;
disabled:opacity-60 (vs default 50) on Save & Apply so it is still
identifiable as a button when no URL is entered yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:22:22 -04:00
Scott Idem
e63a17865c fix(launcher): replace fixed post_delay with frontmost poll across all launch profiles
`open -a App` returns immediately; a fixed delay is unreliable for cold starts.
Each post_script now polls every 500 ms (up to 7.5 s) until the target process is
frontmost before firing the automation keystroke. Keynote polls document count
instead of frontmost since start(front document) requires the file to be loaded.
Mac profiles: post_delay_ms 2000 → 1000. Win/Parallels profiles: 3000 → 1500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:16:01 -04:00
Scott Idem
a59e53aec5 fix(launcher): larger, ring-bordered preset chips for VNC readability; fix lint errors
Bumps chip buttons h-6→h-8 with text-xs and ring-1 borders so active/inactive
states are clearly distinct at VNC/remote scale. Save & Apply bumped to h-10
text-sm. Fixes: /50 opacity modifier in class: directives (uses class expression
instead), stale svelte-ignore comments replaced with onkeydown handlers, each
block key added. Documents wallpaper repeat-apply macOS caching bug in TODO with
workaround and fix location (Electron temp filename).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:29:34 -04:00
Scott Idem
6042095147 feat(launcher): preset chips + simplified buttons for wallpaper config
Replaces free-text-only inputs with quick-select preset chips (1 primary,
4 client external/projector) that populate the URL field on click.
Consolidates Save/Apply/Save&Apply/Restore into a single Save & Apply
primary button plus an icon-only Restore button. All status messages
merged into one shared state variable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:22:01 -04:00
Scott Idem
932deced12 docs: mark IDAA server-side Novi verification resolved in TODO__Agents.md
All Access Denied root causes fixed and deployed 2026-05-19. 503 auto-retry
regression also documented and fixed same session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:47:46 -04:00
Scott Idem
861385b4ff docs(idaa): update Novi verification docs to reflect server-side proxy (complete)
CLIENT__IDAA_and_customized_mods.md:
- Verification Flow: describe Aether proxy call, not direct browser-to-Novi fetch
- Replace old fetch() code snippet with new Aether endpoint call
- Update novi_idaa_api_key / novi_api_root_url field descriptions (server-side only now)
- Security notes: key never sent to browser; shape changes go in backend method
- Rate limit note: 12h TTL (was 5-min), add 503 auto-retry behavior
- Fix Redis cache key: idaa:novi_member:{uuid} (account_id was dropped from key)

GUIDE__AE_API_V3_for_Frontend.md §12:
- 503 frontend action: auto-retry once after 3s before api_error
- Mark migration section complete (2026-05-19); update table to show retry behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:46:51 -04:00
Scott Idem
42f40e990e fix(idaa): auto-retry 503 from Aether Novi proxy (mirrors network-error path)
Before: a 503 response from /v3/action/idaa/novi_member/{uuid} hit !response.ok
immediately, set verify_failed_for_uuid, and showed "Verification Unavailable" —
no automatic retry. In the old client-to-Novi flow an unreachable server threw
TypeError (auto-retried); the new server-side path returns a clean HTTP 503
(plain Error), bypassing the is_network_or_timeout branch.

After: 503 gets the same 3s wait + one retry that network/timeout errors get.
Only if the retry also 503s does it fall through to the error UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:41:01 -04:00
Scott Idem
3ea362c166 fix(idaa): restore site_cfg guard to prevent API call on non-IDAA domains
The server-side migration removed the old novi_idaa_api_key check, which was
also acting as an implicit 'is IDAA configured here?' guard. Without it, any
domain that resolves (including ghost/domain-not-found with account_id='ghost')
would fire the Aether endpoint and get an error response, showing 'Verification
Unavailable' over the root layout's 'Domain Not Found' message.

Restore the site_cfg.novi_idaa_api_key presence check as the first guard:
- key absent → site_cfg_json still loading OR this is not an IDAA site → skip
- account_id='ghost' → domain lookup failed → added explicit ghost guard too

The key itself is unused for auth (server holds it); we only test its presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:57:26 -04:00
Scott Idem
400312456b feat(idaa): replace client-side Novi call with server-side Aether proxy endpoint
verify_novi_uuid() now calls GET /v3/action/idaa/novi_member/{uuid} instead
of fetching Novi directly from the browser. The Aether backend handles the
Novi call server-to-server, eliminating false Access Denied failures caused
by hotel/conference WiFi, VPNs, and Cloudflare IP filtering.

Response parsing simplified — full_name and email are normalized server-side.
Empty-200 anti-pattern handling, email space→+ normalization, and display-name
formatting moved to the backend (confirmed working per API agent).

Retry logic and error classification unchanged (429→rate_limited, network
error→retry once, all others→api_error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:49:18 -04:00
Scott Idem
6755a68b13 fix(idaa): add VPN/network hint, bump TTL to 12h, document server-side verify plan
- Classify persistent network/timeout failures as 'network_error' (separate from
  generic 'api_error') so the UI can show a targeted message
- Add actionable hint for members on hotel WiFi, VPN, or corporate networks:
  turn off VPN, switch to cellular, try a different network
- Extend VERIFIED_TTL_MS_DEFAULT from 45 min to 12 hours — covers a full workday
  so members at conferences do not need to re-verify mid-day
- Document planned server-side Novi verification FastAPI endpoint in
  CLIENT__IDAA_and_customized_mods.md (once implemented, eliminates client-side
  Cloudflare/IP-reputation exposure entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:23:45 -04:00
Scott Idem
71e79f032d docs(idaa): mark Access Denied root cause investigation as resolved
All 10 fixes applied and verified as of 2026-05-19. Collapsed the three
open issues into the completed checklist with commit references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:45 -04:00
Scott Idem
53fd5e7de4 fix(idaa): increase spinner timeout to 35s, guard sessionStorage with try-catch
VERIFY_TIMEOUT_MS 8s → 35s: worst-case auto-retry cycle is 27s (12s abort +
3s wait + 12s abort). At 8s the "Reset & Retry" banner fired while the second
retry was still in flight; members who clicked it cleared their stores and
reloaded mid-attempt, landing on Access Denied. At 35s the escape hatch only
appears if verification is genuinely stuck (slow Novi server or missing api_key).

sessionStorage try-catch: iOS Safari Private Browsing and certain iframe sandbox
configs throw on sessionStorage access. Wrap setItem (onMount) and getItem
(reload_with_uuid) in try-catch so the component degrades gracefully to
location.reload() rather than crashing silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:04:28 -04:00
Scott Idem
14e84884cd refactor(idaa): rename reload_to_origin → reload_with_uuid (clearer intent)
'origin' is a reserved web term (protocol+domain); the function restores the
initial Novi-provided iframe URL (which includes the ?uuid= param), not the
site root. Renamed to reload_with_uuid to make that clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:53:18 -04:00
Scott Idem
e921ca973f fix(idaa): add Novi fetch timeout and auto-retry on network errors
- Wrap Novi API fetch in AbortController with 12s hard timeout — prevents
  verify_in_flight from getting stuck if Novi's server hangs with no response
- Auto-retry once (after 3s) on network errors (TypeError: Failed to fetch)
  and timeouts (AbortError) — these are almost always transient cellular/WiFi
  blips and previously hard-failed with no second chance
- Rate-limit retries (429) already had a 10s wait; network retry is separate
- Update status message to "Connection issue — retrying..." during network retry
- Update error panel hint to suggest closing/reopening the Novi page as last resort
- Update Access Denied hint with same guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:29:14 -04:00
Scott Idem
2855e091f7 fix(idaa): fix Access Denied on reload in iframe and extend Novi TTL to 25 min
- Add reload_to_origin(): saves initial iframe URL (with ?uuid=) to sessionStorage
  on mount; all reload buttons use it instead of bare location.reload() so the UUID
  is preserved after internal SvelteKit navigation strips it from the URL
- Fix TTL short-circuit to also check $ae_loc permissions — without this, a store
  reset (browser restart, stale localStorage) while the TTL was still valid would
  skip re-verification and fall straight to Access Denied
- Extend Novi verification TTL from 5 to 25 minutes
- Add Clear Cache & Reload option to the Access Denied state (iframe mode)
- Move Novi UUID debug info on Access Denied page to edit_mode only; UUID line
  at bottom of auth'd pages stays always visible for troubleshooting
- Remove expired temporary tech-notice variables (template block was already commented out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:22:20 -04:00
Scott Idem
f37c64c68b perf(app): remove unused Google Fonts requests, add pre-JS loading spinner
- app.html: comment out 3 Google Fonts <link> tags (Noto Sans, Noto Serif,
  Roboto Mono) — no theme or component applies these families; all themes use
  system-ui. Saves 3 blocking network requests on every page load.
- app.html: add subtle CSS-only #ae_loader spinner (1.75rem ring, pointer-events:none)
  that shows during JS download + root load function, before Svelte mounts.
- +layout.svelte: add onMount to remove #ae_loader as soon as Svelte bootstraps;
  the existing is_hydrating frosted-glass overlay takes over from there.
- app.css: comment out orphaned Quicksand @font-face — font-family was never
  applied to any element so browser never fetched it anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:19 -04:00
Scott Idem
615af58a11 docs(idaa): update CLIENT doc for error handling and contact search status
- Access Gate: document new verify_error_type states (rate_limited/api_error),
  retry/reset UI buttons added in the previous session
- Search Architecture: correct 'contacts not searchable' — default_qry_str already
  includes contact data; two bugs fixed 2026-05-19 (stale STORED GENERATED columns,
  frontend secondary filter dropping API-matched results). IDB fast-path gap remains.
- TODO__Agents.md: update contact search task to reflect API path now working;
  narrow remaining work to IDB fast-path only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:09 -04:00
Scott Idem
76e21b08ff fix(idaa): remove over-filtering of API text search results in recovery meetings
The secondary client-side filter was re-checking qry_str against name, description,
location_text, and default_qry_str on the API response. This silently dropped meetings
that the API correctly matched via default_qry_str (a backend-generated combined index
containing contact name/email) — because that field is not always present in the
response body.

The API's LIKE search on default_qry_str is already exact. The secondary filter should
only correct structured dimensions (type, physical/virtual OR-logic) where the backend
uses AND logic that the frontend must compensate for. Text search is left to the API.

Root cause confirmed: STORED GENERATED columns in the event table needed a rebuild;
frontend filtering was masking results that the API returned correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:49:52 -04:00
Scott Idem
4ada5c4a8f Lowered the timeout from 8 to 5 seconds 2026-05-19 11:33:24 -04:00
Scott Idem
8850db89c6 fix(layouts): guard appshell header/footer data stores behind account_id
element_data_store fires its load trigger as soon as api_ready is true,
with no check for account_id. In the IDAA iframe flow, the outer layout
mounts before Novi UUID verification completes, so the footer fetch fires
with no x-account-id header and gets a 403.

Wrap the IDAA outer layout footer in {#if $ae_loc.account_id} so it only
loads once the member's identity is established. Apply the same guard to
the events layout header and footer for consistency.

Journals was already safe (data stores are inside the trusted_access gate).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:17:24 -04:00
Scott Idem
ccacdc3f4b Add comments and less debug 2026-05-19 10:55:32 -04:00
Scott Idem
128944c7ab feat(idaa): improve error handling for Novi verification failures and network errors
Distinguish transient API failures (rate limits, server errors, network drops) from
real membership denials, so members see actionable messages instead of 'Access Denied.'

Layout: new verify_error_type state ('rate_limited' | 'api_error') surfaces a
yellow 'Identity Verification Unavailable' banner with three recovery options --
Try Again (no reload, clears latch), Clear Cache and Reload, and Full Reset.
Spinner now shows live status messages (e.g. 'High traffic - retrying in 10 seconds...').

Recovery meetings page: qry_error_detail distinguishes network drops (TypeError /
ERR_NETWORK_CHANGED) from server errors, showing specific guidance in the error UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:46:28 -04:00
Scott Idem
c0386f27bc fix(idaa): fix name A→Z / Z→A sort not applying in API revalidation path
The RE-SORT block after API revalidation only checked for 'name' (a legacy
sort mode removed when sort_modes was introduced). 'name_asc' and 'name_desc'
fell through to the else branch and were silently re-sorted chronologically
by tmp_sort_1, overriding the user's selection. Updated to match the fast-path
IDB sort logic which already handled all three modes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:49:40 -04:00
Scott Idem
ee506832e7 fix(idaa): make novi_btn buttons visible in Bootstrap iframe context
Bootstrap v3 (.btn) sets border:1px solid transparent which overrides
Skeleton/Tailwind preset-outlined border classes when loaded last in the
Novi iframe. Replacing the dead border-color comments with a box-shadow
ring — Bootstrap does not reset box-shadow on .btn so it survives without
!important. Adds a darker ring + faint bg tint on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:41:05 -04:00
Scott Idem
bbab9e7c8c feat(idaa): update default recovery meeting limit to 100 and add 75 to stepper
- Update default qry__limit to 100 in idaa_loc
- Add 75 to limit_steps in recovery meetings query component
- Bump AE_IDAA_LOC_VERSION to 2 to apply changes to existing users
- Update IDAA documentation and TODO__Agents.md with SQL optimization task
- Mark implemented UI/UX ideas as done in documentation
2026-05-18 21:25:09 -04:00
Scott Idem
daf1570781 Updating docs 2026-05-18 18:51:53 -04:00
Scott Idem
c69e40829f feat(idaa): collapsible Meeting Info panel on recovery meetings list
Wrap the data store element in an accordion-style toggle. State persists
in idaa_loc (localStorage) so the user's preference survives page reloads.
Added ds_info_collapsed field to idaa_local_data_struct.recovery_meetings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:59:02 -04:00
Scott Idem
429f38996a refactor(idaa): filter bar polish — cycling sort, centered layout, move into form
- Replace three sort chips with single cycling button (Last Updated → Name A→Z → Name Z→A → repeat)
- Add min-w-36 to sort button to prevent layout bounce between labels
- Move My Meetings chip to filter row (first position) alongside Virtual/In-Person
- Widen filter chips row from max-w-xl to max-w-2xl to match search bar
- Move sort/max/actions section inside <form> so all rows share the same width constraint
- Flatten span wrappers in bottom row — all buttons are direct flex children of justify-center container, fixing left-drift when conditional buttons are hidden
- Add keyed {#each (val)} to Type chip loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:50:43 -04:00
Scott Idem
6857f1226c refactor(idaa): compact filter bar — sr-only labels, +/- stepper for max results
- Group labels (Location, Type, Sort, Max) moved to sr-only — visually hidden,
  accessible to screen readers. Chips are self-descriptive without them.
- Max results chips replaced with a [−] 150 [+] stepper that steps through
  predefined values [25, 50, 100, 150] (+ 200/500 for trusted_access). Minus/plus
  buttons disable at the ends of the list. limit_steps and limit_idx computed as
  $derived in script so onclick closures have access without @const gymnastics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:18:59 -04:00
Scott Idem
bb84117991 feat(idaa): replace Max Results and Sort By selects with pill chips
- Max: 25 / 50 / 100 / 150 chips for all users; 200 / 500 visible to trusted_access
  staff only (consistent with previous select behavior).
- Sort: Last Updated / Name A-Z / Name Z-A chips; clicking triggers the same
  qry__order_by_li update and search_version bump as the old select onchange.
- Sort logic extracted into set_sort_mode() helper to keep onclick clean.
- Active state: preset-filled-tertiary-400-600; inactive: outlined + opacity-60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:11:46 -04:00
Scott Idem
7a1099bbbe feat(idaa): replace filter checkboxes/radios with toggle pill chips + update docs
- ae_idaa_comp__event_obj_qry.svelte: replace Location checkboxes and Type radio
  inputs with styled pill-chip buttons. Location chips (Virtual / In-Person) are
  independent toggles; Type chips (All / IDAA / Caduceus / Family Recovery) are
  mutually exclusive — clicking the active chip deselects back to All. Chips fire
  the reactive search $effect directly via store updates; no explicit trigger needed.
  Remote First dev toggle preserved in edit mode, now inline with filter chips.
- CLIENT__IDAA_and_customized_mods.md: update Recovery Meetings filter/sort docs,
  add My Meetings / favorites section, correct idaa_loc and idaa_sess store schemas,
  bump Last Verified date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:08:01 -04:00
Scott Idem
3a81887c56 feat(idaa): add guided empty state for filtered results + star button on meeting detail
- +page.svelte: when search returns zero results and filters are active, show
  "No meetings found for these filters" with a one-click "Clear all filters" button
  instead of the bare no-results message. The 8s cache-reset escape hatch is
  unchanged and still fires only when zero results appear with no filters set.
- [event_id]/+page.svelte: add star/favorites button to the detail page nav bar
  alongside Back/Edit. Loads the same idaa_meetings_favorites data_store record
  on mount; PATCHes the shared record on toggle. State is optimistic with rollback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:00:51 -04:00
Scott Idem
730fb19d60 refactor(idaa): migrate favorites storage from mod_meetings_json to data_store
Favorites are now stored in a dedicated data_store record (code:
idaa_meetings_favorites, scoped to the IDAA account_id) so toggling
never touches ae_event rows or their updated_on timestamps. Structure:
{ [novi_uuid]: [event_id, ...] }. Pre-created DB records for dev (id 150)
and live IDAA (id 151) accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:38:05 -04:00
Scott Idem
b32fb05138 chore(idaa): note updated_on side effect on favorites toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:16:22 -04:00
Scott Idem
12429ccf2e feat(idaa): add My Meetings favorites for recovery meetings
Store Novi UUID list in event.mod_meetings_json.favorite so favorites
persist cross-browser without requiring Novi API write access. Optimistic
IDB update with API rollback on failure. Star button uses inline styles
to override Bootstrap v3 iframe CSS conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:09:05 -04:00
Scott Idem
2d552b36fd Made the IDAA ideas more staff friendly.
The IDAA Posts now show the person's name even if anonymous.
2026-05-18 15:01:52 -04:00
Scott Idem
3ed1a2a6c4 Documentation updates with IDAA ideas. 2026-05-18 08:56:53 -04:00
Scott Idem
ab9e54d768 fix(idaa): resolve ~1-year 'no meetings found' bug on recovery meetings page
Root cause: stale IDB records from prior deploys persisted indefinitely.
Fast path returned 0 (account_id mismatch), API errored silently, and the
error state showed the same message as a genuinely empty result — making
the failure indistinguishable from real data.

Fix is layered defense:
- Bump IDB_CONTENT_VERSIONS.events.event to 2 (one-time force-clear for all users)
- Add check_and_clear_idb_table() helper to store_versions.ts; wire it in
  (idaa)/+layout.svelte to catch future version mismatches on session start
- One silent auto-retry (3s) on API failure before surfacing error UI
- Distinct error state (Unable to load meetings) separate from empty state
- Escape-hatch cache-reset button after 8s when zero results + no active filters
- Document root cause and fix in README.md and BOOTSTRAP__AI_Agent_Quickstart.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:52:29 -04:00
Scott Idem
5bb2df1bd9 Quick adjustments to badge layout. Hopefully this does not break the other Axonius layout that uses the full background image. 2026-05-15 15:59:08 -04:00
Scott Idem
50c484a4cc fix(badges): fix 4x6 fanfold header image display and body gap
Two layout fixes for the badge_4x6_fanfold layout (no background_image_path):

1. badge_header max-height: 1.5in — the Axonius logo (624×232px) renders at
   ~1.49in tall at full badge width. The inherited max-h-[1.00in] was cropping
   the bottom half of the image.

2. badge_body margin-top: 0 — overrides the component-level mt-54 (≈2.25in).
   That margin was needed for the PVC layout where a full-bleed background image
   covered the badge and body text needed to start in the image's designated zone.
   For fanfold badges with a standalone header_path, mt-54 created a 2.25in blank
   gap between the header and the attendee name.

Also updates default fit_heights for badge_4x6_fanfold to match the 4.0in body
height (was sized for 4.5in before the header zone was properly accounted for).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:37 -04:00
Scott Idem
26fde2a566 feat(badges): add 4×6 fanfold layout CSS for Epson single-sided stock
Introduces badge_layout_epson_4x6_fanfold.css (layout code badge_4x6_fanfold)
for the Axonius Adapt 2026 June show. Wires @page size to 4in×6in in the print
page and cleans up the stale 4in×12in default. Imports new CSS in badge component.
Measured stock: 4in × 6in portrait with 5/8in lanyard hole 1/4in from top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:26:13 -04:00
Scott Idem
21a44f96fa Cleaning up the styles for the Session related content. 2026-05-15 14:44:57 -04:00
Scott Idem
631a77158c feat(pres_mgmt): replace time_hours/time_format/datetime_format with single use_12h toggle
Three redundant store fields encoding the same AM/PM choice replaced with a single
`use_12h: boolean` in PresMgmtLocState. iso_datetime_formatter gains a third param
(use_12h: boolean | null = null) that auto-resolves 24h↔12h format name variants via
a symmetric FORMAT_PAIRS lookup — null default leaves all ~100 existing call sites intact.

Toggle surfaces in three places: Clock icon in session time chip (hidden button, same
visual), event Options modal Display section, and session Options modal Display section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:29:57 -04:00
Scott Idem
1296b1077e Style fixes 2026-05-15 13:56:23 -04:00
Scott Idem
1fe2f6f4d2 fix: correct idaa_trig usage in recovery_meetings Zoom URL editor
$idaa_trig is a key_val object (dict of boolean flags), not a string signal.
Adds update_zoom_full_url key to the store template and converts all 7
string comparisons/assignments to property access on the object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:13:00 -04:00
Scott Idem
73f97ee17b General code cleanup. Mainly updating Lucide icons. 2026-05-15 13:04:50 -04:00
Scott Idem
ad6b390fd9 fix: pres_mgmt session search now includes presenter and presentation names
Previously, searching by presenter name in pres_mgmt returned no results
because event_presenter_li_qry_str / event_presentation_li_qry_str were
never requested or stored.

Changes:
- ae_types.ts: add event_presentation_li_qry_str + event_presenter_li_qry_str
  to ae_EventSession interface
- db_events.ts: same two fields added to Session Dexie interface
- ae_events__event_session.ts:
  * add ft_presentation_search_qry_str param
  * auto-upgrade view to 'alt' when either ft_presenter or ft_presentation
    search is used (backend requires v_event_session_w_file_count for these)
  * add both fields to properties_to_save so they persist to Dexie cache
- +page.svelte (pres_mgmt):
  * pass ft_presenter_search_qry_str + ft_presentation_search_qry_str in API call
  * local IDB fast path now checks both new fields
  * client-side filter guard also checks both new fields
2026-05-15 12:39:37 -04:00
Scott Idem
f297c7c018 fix: account_name not showing on events page — stale Dexie cache + duplicate assignment
Two Svelte-side bugs causing account_name to always show 'Account Name Not Set':

1. ae_core__site.ts: background site_domain refresh only pushed cfg_json back
   into $ae_loc after a stale cache hit. Now also pushes account_name and
   account_code when the store holds a placeholder value.

2. +layout.ts: duplicate ae_loc_init['account_name'] assignment at line ~475
   was overwriting the correct one at line ~385 with a different fallback string
   ('Account Name Not Set' vs 'Ghost Account'). Removed the duplicate.

Also includes user-intentional changes during testing:
- events/+page.svelte: typo fix ('You access' -> 'Your access'); Pres Mgmt /
  Launcher / Badges / Leads buttons now gated on trusted_access && edit_mode
- events/+page.ts: event list limit 25 -> 7
- events/[event_id]/+page.svelte: user edit
2026-05-15 11:46:10 -04:00
Scott Idem
48a748d314 refactor(storage): replace hardcoded IDB lists with indexedDB.databases() in all clear buttons
Consistent with the tech help panel update — all Clear Storage / Clear & Reload
buttons now enumerate IDB databases dynamically so new modules are included
automatically without needing to update these lists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:23:29 -04:00
Scott Idem
c4e2e64a7e Style work 2026-05-14 17:55:05 -04:00
Scott Idem
76b4adef74 feat(help): improve Clear & Reload + add Full Reset button in tech help panel
Reworks Clear & Reload to use indexedDB.databases() (dynamic enumeration)
instead of a hardcoded DB list. Edit mode clears localStorage/sessionStorage
while preserving ae_loc (sign-in + permissions). Normal mode clears IDB only.

Adds new Full Reset button — wipes all IDB, localStorage, and sessionStorage
for the origin with no preservation. Useful for diagnosing quota/storage issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:44:23 -04:00
Scott Idem
95a86b16fa feat(idb): add IDB content version check system, wire to journals
Adds check_and_clear_idb_tables() helper in core__idb_dexie.ts that clears
Dexie tables when their content version changes. Version numbers live in
IDB_CONTENT_VERSIONS (store_versions.ts); state tracked in localStorage
(ae_idb_ver__{module}__{table}) so each table clears exactly once per bump.

Wired into db_journals.ts (pilot): journal_entry at v3 clears stale
content_md_html/history_md_html data cached before the properties_to_save fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:53:55 -04:00
Scott Idem
042265008c fix(journals): remove description_md_html from IDB properties_to_save
Mirrors the content_md_html/history_md_html fix on journal_entry.
description_md_html is computed from description via marked.parse() on
every background refresh and does not need to be persisted to IDB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:19:25 -04:00
Scott Idem
a5f2ae3835 fix(journals): decouple IDB cache writes from API data return
QuotaExceededError on db_save was propagating through .then() into the
outer .catch(), which returned undefined and discarded the successfully-
fetched API data. Wrapped all db_save calls in try/catch so IDB failures
log a warning but never kill the load/create/update return value.

Also removed content_md_html and history_md_html from properties_to_save
— these are computed from content/history on every background refresh via
marked.parse(), so storing the rendered HTML alongside the source was
doubling storage cost per entry and the primary cause of quota exhaustion.

Affects: load, list, create, update, and qry functions in both journal
and journal_entry modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:43:18 -04:00
Scott Idem
b3ce65f7f6 docs: update TODO — mark error handling, PWA SW, launch_profiles editor as done; annotate slide control + kill_processes status
- Error handling + fallback: confirmed done (launcher_file_cont.svelte)
- PWA service worker chrome-extension guard: confirmed done (service-worker.js)
- Launcher config UI / launch_profiles editor: confirmed done (launcher_cfg_launch_timing.svelte)
- Slide control scripts: annotated partial state — Svelte buttons wired, Electron scripts still hardcoded, deferred post June 10
- kill_processes: documented as not started on Svelte side; noted Device tab length concern

Also bundle two prior-session Launcher fixes:
- VLC post_delay_ms: 2000 → 1500ms
- launcher_cfg_launch_timing: add min-w-22 to built-in/current delay display
2026-05-14 12:24:36 -04:00
Scott Idem
054775b0f8 feat(launcher): skip wallpaper gsettings on Linux, show dev popup instead
Running gsettings on the dev workstation resets monitors on every test cycle.
Linux Electron handler now returns linux_test_mode:true with would_run details
instead of applying. Svelte cfg component shows a debug popup (mirrors Native
Test Mode style). Background sync logs to console and leaves applied-URL unset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:54:56 -04:00
Scott Idem
af28fba263 feat(launcher): restore macOS default wallpaper + external-only apply fix
- electron_relay: add restore_macos_default_wallpaper() — uses run_osascript
  to find first .heic in /System/Library/Desktop Pictures/ (version-agnostic)
- wallpaper cfg: Restore macOS Default button (native or edit_mode); clears
  applied-URL tracking so next configured URL re-applies correctly
- wallpaper cfg: fix Apply Now / Save & Apply enabled when only external URL
  is filled; display target becomes 'external' to leave built-in unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:37:15 -04:00
Scott Idem
17e522f826 feat(launcher): wallpaper auto-apply from device config
Stores wallpaper URL(s) in event_device.other_json.launcher.wallpaper and
auto-applies on all native Launcher instances within one heartbeat cycle (~60s),
eliminating manual per-Mac setup at events.

- electron_relay: typed set_wallpaper wrapper (url, url_external, display, auth headers)
- launcher_defaults: wallpaper_applied_url tracking + section_state__wallpaper
- launcher_cfg_wallpaper: new config section — save to device config + apply now
- launcher_cfg: add wallpaper section to device tab
- launcher_background_sync: auto-apply if config URL changed since last apply;
  external-only config targets only the secondary display, leaving built-in unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:31:39 -04:00
Scott Idem
324f3a97ac Doc update 2026-05-13 17:47:49 -04:00
Scott Idem
530853a78d Version bump because I think things are in a pretty good state. 2026-05-13 17:29:59 -04:00
Scott Idem
453fcf581d Remove dead recovery meetings search prefill 2026-05-13 17:24:08 -04:00
Scott Idem
530b53aa6d Fix IDAA recovery meetings auto search 2026-05-13 17:00:36 -04:00
Scott Idem
cc990084fb Updated version number... 2026-05-13 16:06:40 -04:00
Scott Idem
5fdb0d1d87 Add service worker self heal on version mismatch 2026-05-13 15:46:26 -04:00
Scott Idem
44cc538ce0 Disable service worker on localhost 2026-05-13 15:30:08 -04:00
Scott Idem
978a9a6960 Clarify service worker cross-origin guard 2026-05-13 15:13:27 -04:00
Scott Idem
82430649db Fix iframe reload and service worker caching 2026-05-13 15:08:17 -04:00
Scott Idem
cdcec259f7 fix(launcher): bypass downloads for url files 2026-05-13 13:59:43 -04:00
Scott Idem
e5883cd53c fix(launcher): skip url files in sync 2026-05-13 13:36:57 -04:00
Scott Idem
8d5c5e39c9 fix(launcher): guard health device metadata 2026-05-13 12:50:25 -04:00
Scott Idem
39749c608a fix(launcher): use per-profile timing overrides 2026-05-13 12:48:43 -04:00
Scott Idem
4923099cfb feat(launcher): add device launch timing override 2026-05-13 12:34:36 -04:00
Scott Idem
36bd32f172 docs(launcher): document post delay override 2026-05-13 12:22:11 -04:00
Scott Idem
1374f0728e refactor(launcher): canonicalize default profiles 2026-05-13 12:15:13 -04:00
Scott Idem
c79ae92be0 docs(launcher): align launch profile terminology 2026-05-13 11:50:13 -04:00
Scott Idem
49c6a2351e refactor(launcher): use launch_profiles only\n\nRemove the temporary launch_scripts compatibility alias and keep the launcher
configuration surface focused on launch_profiles everywhere in the Svelte app and
docs.
2026-05-13 10:30:10 -04:00
Scott Idem
b697126495 refactor(launcher): prefer launch_profiles naming\n\nRename the public launcher override concept to launch_profiles across the task list\nand docs, while keeping launch_scripts as a compatibility alias for older device
records. Update the Svelte resolver to read both keys so per-device tweaks remain
backward compatible during the transition.
2026-05-13 10:26:01 -04:00
Scott Idem
8dd22912c3 fix(launcher): keep native open state off download spinner\n\nAdd an opt-out on the shared hosted-file button so promise-returning native opens\ncan use Launcher status text without being treated like active downloads. Apply it\nto the Electron launcher open path so cache hits no longer show 'Downloading...'\nwhen the row status already reports the correct cache/open state. 2026-05-12 13:49:55 -04:00
Scott Idem
f8fe4ac5a2 fix(launcher): button state hygiene across all 3 modes
Fix 1 (stale error_detail):
  open_file_error_detail is now cleared at the start of every mode's
  branch (native, onsite, default) alongside open_file_clicked=true.
  Previously it was cleared mid-way through the native flow (Step 1),
  so a stale error from a previous failed run could flash briefly.

Fix 2 (stuck 'Opening...' during post_script sleep):
  After the Step 4 open call returns (run_cmd or open_local_file_v2),
  update the status message immediately to 'App opened — running setup...'
  so the button doesn't appear frozen for the full post_delay_ms wait.

Fix 3 (safety valve for hanging native calls):
  A 60s setTimeout is registered at the start of the native branch.
  If any native IPC call hangs indefinitely and the existing per-path
  reset timeouts never fire, this force-resets open_file_clicked after
  60s. All normal paths complete within ~8s so this only triggers on
  true hangs. ERR_NETWORK_CHANGED cannot re-enter the download path
  because the open_file_clicked guard blocks re-entry.
2026-05-12 13:37:11 -04:00
Scott Idem
2c1e9d294e fix(launcher): swallow orphaned IPC reply on open_local_file_v2
shell.openPath always resolves on the Electron side, but if the Svelte
renderer navigates before the IPC reply arrives, the promise rejects
with 'reply was never sent'. The file is already open at that point.

Catch the rejection and treat it as success on both call sites (Step 4
primary path and Step 7 fallback). This eliminates the unhandled
promise error without masking real failures.
2026-05-12 13:18:53 -04:00
Scott Idem
768fdbfb21 feat(launcher): URL-type event_file support + displayplacer Electron task
URL files: event_file.filename = 'https://...' is now a first-class
file type in the launcher.

- is_url derived rune detects https/http filename prefix
- URL branch in handle_open_file() runs before cache/native branches
  (no download, no temp copy, no hash)
- Offline guard: warns and blocks click if navigator.onLine is false;
  online/offline listeners registered only for URL rows (no-op on files)
- Native mode: opens via native.open_external({ url, app: 'chrome' })
  with silent fallback to default browser
- Browser mode: window.open() with noopener
- display_mode default: 'mirror' (URLs typically just need the screen
  mirrored, not extended presenter view)
- Button badge shows Link2 icon + WifiOff warning when offline
- Button text uses event_file.title as label (falls back to URL)
- Test mode popup: skips Steps 1-2 (N/A), shows open_external call

DEFAULT_LAUNCH_PROFILES: add 'url' key (display_mode: 'mirror')

Electron TODO: added set_display_layout / displayplacer per-device
config task to aether_app_native_electron/documentation/TODO_AGENTS.md
with full contract details and resources
2026-05-12 13:14:58 -04:00
Scott Idem
e74dc7a388 fix(launcher): skip post_delay sleep when no post_script
The sleep step was running unconditionally, meaning files that open
with the 'default' catch-all profile (zip, unknown ext, etc.) would
wait 2 seconds for no reason — there's no AppleScript to prepare for.

Gate the sleep inside the post_script check so it only delays when
there's actually automation to run after app launch.

Also update the test mode popup to show '0ms — skipped (no post_script)'
and display post_delay_ms as '(default: 2000ms)' when unset.
2026-05-12 12:49:50 -04:00
Scott Idem
a3f2f17480 Allow npx svelte check 2026-05-12 12:41:14 -04:00
Scott Idem
6c73812187 fix(launcher): improve test mode popup contrast for light/dark mode
- Warning text in cfg_app_modes: replace text-yellow-400/70 with
  preset-tonal-warning badge (theme-aware, readable in both modes).
- Popup dialog: bg-surface-900 → bg-surface-50/95 dark:bg-surface-900/95
  so the card adapts to light/dark instead of being locked dark.
- Code blocks: bg-black/30 → bg-surface-500/10 (neutral opacity).
- Header title: text-warning-400 → text-warning-600 dark:text-warning-400.
- Semantic text colors: all -300 variants → 500-level equivalents:
  text-green-300/400 → text-success-600 dark:text-success-400
  text-primary-300 → text-primary-500
  text-yellow-300/400 → text-warning-500
  text-purple-300 → text-purple-500
2026-05-12 12:40:39 -04:00
Scott Idem
ff824ebbe5 feat(launcher): add Native Test Mode for profile/command preview
Enables testing the LaunchProfile system from any device (no Mac/Electron
needed). When active, the Open button simulates the full native flow and
shows a debug popup with everything that WOULD be sent to Electron.

- ae_events_stores__launcher_defaults.ts: add native_test_mode boolean
  (persisted, default false) to LauncherLocState and launcher_loc_defaults.

- launcher_cfg_app_modes.svelte: add Native Test Mode checkbox toggle in
  the Advanced Toggles (Edit Mode Only) section with active-state warning.

- launcher_file_cont.svelte:
  - Add test_mode_popup_open/test_mode_popup_data state vars.
  - Add branch 0 in handle_open_file(): when native_test_mode + app_mode=native,
    skip all Electron calls; resolve the real LaunchProfile, build a data
    snapshot, open the debug popup.
  - Debug popup shows: file info, simulated temp path, cache/copy pass,
    resolved LaunchProfile fields, set_display_layout call, open command
    (run_cmd or open_local_file_v2 fallback), sleep delay, post-script
    (AppleScript or shell: prefix). Click backdrop or Close to dismiss.
2026-05-12 12:33:24 -04:00
Scott Idem
422c9c341c feat(launcher): implement LaunchProfile system — MasterKey replacement
- Add ae_launcher__default_launch_profiles.ts with LaunchProfile interface,
  DEFAULT_LAUNCH_PROFILES constant, and resolve_launch_profile() helper.
  Covers pptx/ppt/key/odp/pdf, all VLC media formats, Windows/Parallels
  variants (pptxwin/pptwin/odpwin/pdfwin), and a catch-all 'default'.

- Replace get_launch_script_template() with get_launch_profile() in
  launcher_file_cont.svelte. Override priority: device API config >
  local persistent config > built-in defaults > 'default' catch-all.

- Rewrite handle_open_file() native branch with 9-step profile-driven flow:
  copy_from_cache_to_temp → resolve profile → set_display_layout (silent fail)
  → open (run_cmd or OS default) → sleep(post_delay_ms) → run post_script
  → fallback to OS default on open failure → surface status/error detail.

- Add open_file_error_detail state var; show error pre block in status
  alert for native error state, show fallback note for fallback state.

- Add display override toggle button in event_file_meta (visible when
  trusted_access + is_native): cycles null → extend → mirror → null,
  PATCHes event_file.cfg_json.display_override via V3 CRUD.
2026-05-12 12:17:43 -04:00
Scott Idem
a3d229c803 docs(launcher): CMSC Charlotte task breakdown + doc date fix
- TODO__Agents: expand CMSC Charlotte launcher task into done/remaining
  sections; enumerate Svelte-side migration items (default templates,
  composable open flow, error fallback, slide control config, kill list
  config, Launcher config UI editor, end-to-end Mac test gate)
- PROJECT__AE_Events_Launcher_Native_integration: update Last Updated to 2026-05-11
2026-05-11 17:34:48 -04:00
Scott Idem
f72454f379 docs(launcher): sync native integration doc with current Electron implementation
- Bootstrap lifecycle now reflects actual two-step flow (event_device → site_domain/search).
  Removes stale /v3/data_store/code reference and corrects the JWT claim — auth is
  x-aether-api-key + x-account-id throughout; no JWT is issued or used.
- check_hash_file_cache corrected to check_cache (actual method name in preload bridge).
- cleanup_tmp_files removed from relay list — stale .tmp cleanup is handled inline inside
  download_to_cache, not via a standalone IPC channel.
- Known Issue section replaced: all AppleScript handlers are now hardened (launch_presentation
  converted to temp-.scpt approach in the companion Electron commit ca4fddd).
- Markdown lint fixes: blank lines around tables, table pipe spacing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:16:01 -04:00
Scott Idem
c5c5292715 feat(launcher): configurable launch scripts + composable native primitives
- electron_relay: type launch_from_cache with script_template param;
  add copy_from_cache_to_temp export; add JSDoc for run_osascript hardening
- launcher_file_cont: add get_launch_script_template() helper reading from
  device-level (event_device.data_json.launch_scripts) and event-level
  (events_loc.launcher.launch_scripts) config; wire into handle_open_file()
- PROJECT__AE_Events_Launcher_Native_integration.md: add Section 8
  (Configurable Launch Scripts); update IPC reference for new/changed handlers
- MODULE__AE_Events_PressMgmt_Launcher.md: add configurable launch behavior
  note to Native Mode Safe Handover section
2026-05-11 13:48:54 -04:00
Scott Idem
8ed7e0f8d7 Remove _random field handling from event_session and event_file modules
Drop the _random key copy loop from _process_generic_props in both files —
V3 API returns {obj_type}_id directly as the random string ID, so copying
from {obj_type}_id_random is a no-op. Simplify .id assignment to use
baseIdKey only. Remove commented-out _random entries from properties_to_save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:31:27 -04:00
Scott Idem
68e5e01df1 A quick backup of the todo before removing things. 2026-05-11 12:26:51 -04:00
Scott Idem
611b1e6b51 Remove _random ID references and fix hosted file download ID
- Fix download button to use hosted_file_id instead of id (which resolved
  to event_file_id via _process_generic_props, hitting the wrong endpoint)
- Fix Dexie file table query in event_file_obj_tbl_wrapper to use _id
  fields (the indexed ones) instead of _id_random variants
- Remove _random fields from properties_to_save in event, event_session
- Drop _id_random fallbacks from launcher device ID resolution and
  background sync heartbeat
- Clean up dead comments and old FA anchor in post edit component
- Update TODO__Agents.md: BGH section removed, CMSC/Axonius shows added,
  download button fix marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:26:21 -04:00
Scott Idem
1ef9080cda Hide journal entry AI tools unless admin edit mode is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:24:10 -04:00
Scott Idem
66c0be65c4 Show AI tools only in edit mode; open existing summary without regenerating
- Hide AI tools panel when entry is not in edit mode
- Clicking AI button when a summary already exists opens it directly
  instead of triggering a new API call; Re-run still available in modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:22:16 -04:00
Scott Idem
bdba092de0 Polish journal entry save buttons and header controls
- Add hover titles to all save buttons
- Match warning color scheme across floating, inline, and header save buttons
- Fix floating save button visibility (Tailwind v4 hidden/md:inline-flex conflict)
- Hide floating save when no unsaved changes using {#if}
- Hide Config button when not in admin edit mode
- Remove the mobile/backup explicit Save button from header (redundant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:15:52 -04:00
Scott Idem
0fa93d7ee5 Fix auto-save stopping after the first save in journal entry editor
The auto-save $effect wrapped has_unsaved_changes in untrack(), which meant it
was never tracked when save_status was 'saved' (JS short-circuit in the else-if
branch). After every successful save the effect lost its reactive dependency on
user edits and never re-fired until something else changed save_status first.

Fix: track tmp_entry_obj.content and tmp_entry_obj.name directly (void reads)
so the effect re-runs on every keystroke and the debounce timer resets correctly
(fires 2 s after the last change, not the first). has_unsaved_changes is also
tracked directly so the status indicator resets cleanly when changes are cleared.
All side-effects remain in untrack() to prevent reactive loops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:21:22 -04:00
Scott Idem
847d653b5e Truncate journal and entry names in browser tab titles
Entry: 50 chars for entry name, 30 for journal name
Journal: 60 chars for journal name
Appends ellipsis (…) when truncated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:08:41 -04:00
Scott Idem
cd01a87143 Fix browser tab titles for journal and journal entry pages
Journal: [Journal Name] - OSIT's AE Journals
Entry:   [Entry Name] - [Journal Name] - OSIT's AE Journals

Entry page title was previously commented out; now active with correct format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:00:31 -04:00
Scott Idem
60ecd221b4 Add tab indentation and line number toggle to CodeMirror editor
- Wire indentWithTab into keymap (Tab=indent, Shift-Tab=dedent, 4 spaces)
- Set indentUnit to 4 spaces
- Wrap lineNumbers() in a Compartment for live toggle without editor rebuild
- Add Hash toolbar button to toggle line numbers; respects show_line_numbers prop as initial value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:55:42 -04:00
Scott Idem
e3a3ab7de8 Fix audio player controls not rendering in Chromium
Chromium's native audio player shadow DOM collapses when max-height is
applied to the <audio> element — audio plays but controls are invisible.
Remove the inline style (carried over from video context) and use w-full
max-w-lg instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:19:30 -04:00
Scott Idem
3553809f27 Add code field to archive content edit form and IDB
- Expose archive_content.code in edit form (trusted + edit_mode only)
- Add code to properties_to_save so it persists on every API load/save
- Add code field + index to Archive_Content Dexie interface (schema v2)
- Minor: center "Add" button rows in archive and content list components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:42:15 -04:00
Scott Idem
6e700e7b4d Remove redundant saving status from IDAA archive edit forms
XHR upload % in the button + disabled states now communicate
upload/save progress; the top Saving.../Finished saving block
is no longer needed (and its out:fade was broken on re-entry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:13:23 -04:00
Scott Idem
d7b4d8c37c Hide drop zone container during upload to fix empty border flash
The outer bordered div was always in the DOM; only the label and input
inside were hidden during upload, leaving a visible empty dashed box.
Apply the same hidden guard to the container div.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:53:14 -04:00
Scott Idem
d9f704fe25 Enable upload progress tracking in both file upload components
Pass track_progress: true to post_object so the XHR path fires live
percent_completed events, making the upload % display functional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:46:42 -04:00
Scott Idem
af74b52481 Add XHR upload path with real-time progress tracking to post_object
New track_progress param (default false) switches to XMLHttpRequest for
form_data uploads so xhr.upload.onprogress can fire percent_completed
postMessages into api_upload_kv. fetch() has no upload progress events.
No retry loop on XHR path — silently retrying a large video upload is
bad UX; caller re-submits on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:46:38 -04:00
Scott Idem
730eea4ce7 Limit archive content upload to single file; improve file section UX
Restrict upload to one file (each archive content item maps to one file);
contextual toggle button text (Switch to Select / Switch to Upload);
swap FontAwesome upload icon for Lucide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:19 -04:00
Scott Idem
09f1a6ee57 Fix .ttf accept typo and remove $bindable from log_lvl in event file upload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:15 -04:00
Scott Idem
64c3afe564 Clean up hosted files upload component
Guard task_id effect against resetting mid-upload; add prevent_default
wrapper; add 20-min timeout for large video/audio files; add null result
guard before result[0]; fix for= attribute to use variable; console.error
on failure; remove dead params/comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:33:12 -04:00
Scott Idem
54dfd734e6 Replace _random archive ID variants with V3 canonical field names
archive_obj.archive_id_random → .archive_id in load function and post-create
assignment; remove archive_id_random and hosted_file_id_random from editable
fields list — V3 returns the random string as the primary ID field directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:58:03 -04:00
Scott Idem
c7ebeebe29 Add dirty-tracking to Archive Content edit: disable Save, hide Cancel when unchanged
- ArchiveContentForm interface + factory for controlled input bindings
- obj_changed bindable prop wired to Cancel button visibility in parent page
- Split Save button: edit mode disables when clean, create mode always enabled
- Post-upload/select/remove syncs orig snapshot so file ops do not dirty the form
- Fix archive_content_id_random / archive_id_random → V3 field names in edit component
- Add missing file_extension field to ae_ArchiveContent type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:57:58 -04:00
Scott Idem
c71fc65be9 Fix archive content upload not patching record after file upload
Svelte 4 store nested property mutations don't call set()/update(), so
$effect on $idaa_slct never fired after upload. Replaced store property
binds with local $state variables that Svelte 5 proxies track natively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 16:26:01 -04:00
Scott Idem
8b7597906f Tighten Jitsi report table padding
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 15:05:05 -04:00
Scott Idem
c289268550 Fix Jitsi report dark surfaces
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:53:41 -04:00
Scott Idem
09a5178b89 Add Jitsi reports staff link
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:44:00 -04:00
Scott Idem
e64252b839 Refine Jitsi participant copy
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:39:45 -04:00
Scott Idem
25e35f6f96 Add Jitsi participant copy actions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:29:27 -04:00
Scott Idem
74bc3b3625 Use 1000-row Jitsi pages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:21:08 -04:00
Scott Idem
cd868460fe Fetch all Jitsi report rows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 14:03:47 -04:00
Scott Idem
6ebf4f125d Better styling for toggle
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 12:52:57 -04:00
Scott Idem
0ae8cf63d7 Improve Jitsi iframe toggle contrast
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 12:49:55 -04:00
Scott Idem
d32312653d Fix Jitsi report iframe title contrast
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 12:26:32 -04:00
Scott Idem
f5155eba50 New template page for IDAA and their Jitsi reports page. 2026-05-06 12:23:39 -04:00
Scott Idem
392217e66c Refine Jitsi report edit-mode controls 2026-05-06 12:10:41 -04:00
Scott Idem
7497bfb9f8 Tighten Jitsi report exclusions
Use Jitsi url_params.uuid for exclusion where available, preserve url_params in cached activity logs, and add the temporary staff-name fallback behind the same edit-mode toggle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 11:47:43 -04:00
Scott Idem
3ae9d0a884 Refine IDAA Jitsi reports UX
Add Novi UUID exclusion and known-meeting filtering, default the report date range to the last 60 days, and hide Room Name unless global edit mode is enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 10:39:42 -04:00
Scott Idem
409308d2be Refine Jitsi docs and bootstrap notes
Keep the bootstrap quickstart focused on general platform knowledge, while preserving the Jitsi Reports reminder in the project docs and todo list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 17:43:31 -04:00
Scott Idem
62cc26d1f9 Making things prettier:
npx prettier --write src/routes/journals/
2026-05-05 17:27:48 -04:00
Scott Idem
8b087edeb9 Add journal entry follow-up notes
Document the remaining Journal Entry Config follow-ups: toggle contrast, footer button styling, passcode auth behavior, AI summary shortcut, Archive On sizing, and Archive On behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 17:26:28 -04:00
Scott Idem
54707a00e3 Refine journal entry config
Polish the Journal Entry Config modal to match the desired section outline, hide alert messaging unless enabled, update the shared draft typing for entry flows, and replace deprecated privacy icons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 17:14:20 -04:00
Scott Idem
e5c8500bc1 Advance journal config modal parity 2026-05-05 14:56:10 -04:00
Scott Idem
07dd213cfd Refine journal description editor layout 2026-05-05 14:36:01 -04:00
Scott Idem
1c20038a55 Align AI modal with journals config style 2026-05-05 14:12:55 -04:00
Scott Idem
d8616ea5fd Normalize Journals config tabs 2026-05-05 14:10:12 -04:00
Scott Idem
0b04ce7c0c Add Jitsi reports to IDAA 2026-05-05 14:02:52 -04:00
Scott Idem
146682a30b Modernize AI tools token input 2026-05-05 13:33:40 -04:00
Scott Idem
20d8a6975d Align journal docs with current model 2026-05-05 13:31:19 -04:00
Scott Idem
80957316f2 Normalize journal entry config actions 2026-05-05 12:59:30 -04:00
Scott Idem
0d0cec9819 Tighten AI config autofill handling 2026-05-05 10:35:35 -04:00
Scott Idem
0705fa8de4 Tweak the wrapping for small width. 2026-05-04 19:05:04 -04:00
Scott Idem
5846981c48 Refine journal entry AI tool layout 2026-05-04 19:00:57 -04:00
Scott Idem
3ca0f0bad9 Wire journal AI tools into entry view 2026-05-04 18:41:03 -04:00
Scott Idem
7486150aab Fix journal entry layout scrolling 2026-05-04 18:32:38 -04:00
Scott Idem
c3a346cc9a Add responsive journal sidebar 2026-05-04 17:42:13 -04:00
Scott Idem
7fd8c976bf Hide empty journal attachments 2026-05-04 17:35:36 -04:00
Scott Idem
9ed2d21757 Stabilize journal entry width 2026-05-04 17:25:08 -04:00
Scott Idem
38a752fbae Gate journal filters by access level 2026-05-04 16:59:33 -04:00
Scott Idem
285ef84b7e Refine journal search filtering 2026-05-04 16:58:48 -04:00
Scott Idem
5cbdec3b5c Reset BB dirty state after save 2026-05-01 18:53:28 -04:00
Scott Idem
8a23e7b7b3 Clean BB detail view wiring 2026-05-01 18:43:19 -04:00
Scott Idem
cc5a6887c0 Stabilize BB iframe width 2026-05-01 18:41:13 -04:00
Scott Idem
89c05cc323 Show Novi IDs in BB read views 2026-05-01 18:31:36 -04:00
Scott Idem
0631937e18 Dim locked Novi identity fields 2026-05-01 18:15:20 -04:00
Scott Idem
20bf1d94eb Improve IDAA BB post editing 2026-05-01 17:34:18 -04:00
Scott Idem
878ff91c30 feat(api): migrate send_email to v3 action endpoint 2026-05-01 15:53:05 -04:00
Scott Idem
7cef6be54c docs(core): mark data store fallback temporary and list special cases 2026-05-01 14:31:19 -04:00
Scott Idem
19822c4eaf docs(security): narrow x-no-account-id guidance and JWT notes 2026-05-01 13:59:07 -04:00
Scott Idem
d5e5cb7ada fix(idaa): gate jitsi report load and restore data store fallback 2026-05-01 13:45:24 -04:00
Scott Idem
e7b6045580 Updates to the documentation.
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 17:13:11 -04:00
Scott Idem
a1ebeddf9d fix(core): clarify account fallback source and pretty-print _json payloads 2026-04-30 17:00:53 -04:00
Scott Idem
2f5ad8ccc0 fix(core): preserve account context on key params and harden account detail fallback
- api_get/post/patch_object: stop treating params.key as account-bypass trigger\n- account detail: remove forced key usage, add list/cache fallback path\n- account detail: fix fallback bug that set load_error even when fallback record existed\n- sites detail: pretty-print cfg_json before save\n- docs: clarify key != bypass and add 403 troubleshooting notes
2026-04-30 16:37:54 -04:00
Scott Idem
90adb19f5d fix(core): modern Svelte 5 cleanup — Dexie .get() bug, typed API calls, inline confirms
- person_view.svelte: fix liveQuery using .get() (primary key, never set by V3)
  → .where('person_id').equals().first()
- people/[person_id]: same Dexie .get() fix for lq__person_obj
- person_view.svelte: replace 4x generic api.update_ae_obj → core_func.update_ae_obj__person
  (removes unused api import)
- Replace all browser confirm()/alert() dialogs (9 occurrences, 6 files) with
  inline two-click confirm state pattern (confirm_action = $state<string|null>)
  Affected: users, accounts, contacts, addresses, people, sites
- Bootstrap doc: add Dexie .get() trap to Section 5 and Mistake #8
2026-04-30 16:00:20 -04:00
Scott Idem
7be60c2b8b fix(core): replace legacy *_id_random with V3 short-form IDs across all core pages
- sites, accounts, addresses, contacts, users list/detail pages
- ae_comp__person_obj_tbl: fix bulkGet→where/anyOf, rename prop person_id_random_li→person_id_li
- person_view: ~20 person_id_random refs in API calls/props
- people page + search + form components
- activity_logs: intentionally unchanged (person_id_random is a real field there)
2026-04-30 15:41:28 -04:00
Scott Idem
bb6782cc32 Clarifying the message about the UUID missing from the URL param. 2026-04-30 15:17:25 -04:00
Scott Idem
51b7f267e9 fix(auth): guard passcode check against missing site_access_code_kv
When the site domain resolves to ghost (not found or missing access key),
$ae_loc.site_access_code_kv is undefined, causing a TypeError on .super.length.

Add early return if kv is absent and use optional chaining on each access
level so the function gracefully returns "no match" on unregistered domains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:33:27 -04:00
Scott Idem
de07fa0e0e docs: capture IDAA IDB audit results and layout security model
- TODO__Agents.md: mark IDAA IDB caching item complete (audited 2026-04-28);
  all protection layers confirmed in place, no code changes needed
- GUIDE__SvelteKit2_Svelte5_DexieJS.md: add "SvelteKit Layout Hierarchy:
  Security and Execution Order" section explaining execution order, auth-gate
  consequences, pre-gate risks in +page.ts/+layout.ts, and the reactivity-guard
  vs auth-guard distinction for IDAA $effect blocks
- BOOTSTRAP__AI_Agent_Quickstart.md: add Mistake #7 — treating $effect blocks
  as auth bypass risks vs understanding the real layout hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:10:17 -04:00
Scott Idem
b4f0ca3e64 fix(auth): re-enable ?key= access gate with persistent-state fix
The key gate was disabled 2026-04-01 after a page-refresh lockout bug.
Root cause: +layout.ts unconditionally wrote ae_loc_init['allow_access'],
which the +layout.svelte merge spread clobbered the persisted key string
on every navigation/refresh without ?key= in the URL, causing the gate
comparison to fail and showing "Access Denied".

Fix: only write allow_access to ae_loc_init when access_key is present
in the URL. On refresh/navigation without the key param, the persisted
value survives the spread unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:10:10 -04:00
Scott Idem
6507fb82c0 Bug fix for showing and hiding location filter part.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 14:13:06 -04:00
Scott Idem
d692d7cfde Minor code clean up, style improvements, and bug fixes.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 13:53:12 -04:00
Scott Idem
fdee7c16ca fix(auth): harden magic-link root_url and clean up stale array-response code
- Defensive fallback for root_url: $ae_loc.base_url || window.location.origin
  so the backend email builder always gets a valid URL (guide warns that a null
  root_url produces a broken magic link "None?user_id=...")
- handle_lookup_user_email: drop stale array-response branch; use user_id (V3
  primary field) instead of user_id_random (legacy alias, same value)
- handle_change_password: same cleanup — user_id preferred over user_id_random,
  dead array-response else-if removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:40:59 -04:00
Scott Idem
4d08994e79 docs: sync updated frontend API guide for user auth endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:34:55 -04:00
Scott Idem
bbdfe75866 fix(auth): migrate sign-in from legacy /user/* to V3 action endpoints
Legacy GET /user/authenticate and GET /user/lookup_email were returning 404
because the backend has removed those routes. Updated all 5 auth functions in
ae_core__user.ts to use V3 equivalents:

- auth_ae_obj__username_password: GET /user/authenticate → POST /v3/action/user/authenticate (body)
- auth_ae_obj__user_id_user_auth_key: GET /user/authenticate → POST /v3/action/user/authenticate (body)
- send_email_auth_ae_obj__user_id: GET /user/{id}/email_auth_key_url → GET /v3/action/user/{id}/email_auth_key_url
- qry_ae_obj_li__user_email: GET /user/lookup_email → POST /v3/crud/user/search
- auth_ae_obj__user_id_change_password: PATCH /user/{id}/change_password → POST /v3/action/user/{id}/change_password

Credentials are now in the POST body (not query params) for authenticate calls.
Updated two call sites in e_app_sign_in_out.svelte to drop removed null_account_id param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:12:52 -04:00
Scott Idem
56e23f3da0 fix(files): normalize file extension to lowercase before legacy/untrusted checks
Filenames like .PPT or .Ppt bypassed the extension checks entirely because the
comparison was case-sensitive. Lowercasing guessed_extension at the point of
computation fixes this for all checks (legacy, untrusted, block_upload).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 11:39:57 -04:00
Scott Idem
4ae9ecc381 fix(files): show legacy file warning banner for trusted-access users
Trusted-access users (Pres Mgmt admins) were getting file_list_status='ready'
when selecting .ppt/.doc/.xls files, so the prominent warning banner never
rendered — only the small per-row warning in the file table was visible.

- element_input_files_tbl: introduce 'warn_legacy' status for trusted users;
  show a yellow warning banner (vs red blocked banner for non-trusted users)
- ae_comp__event_files_upload: change button disabled check from != 'ready'
  to === 'blocked_legacy' so 'warn_legacy' does not accidentally block upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:24:55 -04:00
Scott Idem
3fd6b33d6f fix(events): prune stale event_file records from Dexie after list refresh
bulkPut only upserts — files deleted on the server stayed in Dexie forever,
showing in the Launcher and Manage Files UI until the browser cache was cleared.

After each _refresh_file_li_background call, deleted records are now pruned
from Dexie. Scope-guarded so we only remove records that would have appeared
in the query (e.g. hidden files are not pruned after a hidden='not_hidden' fetch).
Also covers the disable (enable=false) case the same way.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:02:40 -04:00
Scott Idem
e15a26f6c6 fix(launcher): load location files into Dexie on location select/refresh
refresh_location_config() was missing inc_file_li:true, so location-level
files were never fetched from the API and lq__location_event_file_obj_li
always returned empty from Dexie. Files only appeared when Pres Mgmt had
previously loaded them on the same device.

Also added a reactive $effect so files load immediately when the operator
switches rooms, rather than waiting up to 60s for the next timer tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:22:11 -04:00
Scott Idem
f8e34b10b8 docs(todo): document download button ID resolution bug and file.clear() scope issue
Both found during 2026-04-22 late-night review of Manage Files upload/download flow.
Downloads confirmed working despite wrong ID (backend silently accepts event_file_id
at hosted_file endpoint). Needs proper fix before backend tightens validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 02:23:08 -04:00
Scott Idem
29c5a9fa82 fix(pres_mgmt): hidden files now visible in Manage Files without manual refresh
Background file loads for session, presentation, and presenter were using the
default hidden='not_hidden', so hidden files never reached Dexie. The Manage
Files liveQuery reads straight from Dexie, making hidden files completely
invisible until the Refresh button was clicked (which already used hidden='all').

The Launcher is unaffected — it has always had a render-time guard that hides
files with event_file_obj.hide unless show_content__hidden_files is enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:54:54 -04:00
Scott Idem
18cbe256de fix(pres_mgmt): increase file upload timeout to 20 min, guard null result
- Set post_object timeout to 1200000ms (20 min) for hosted file uploads;
  the 90s default was killing large presentation file uploads
- Guard result[0] access in .then() to prevent crash when upload
  times out or is aborted (TypeError: can't access property "hosted_file_id")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:36:14 -04:00
Scott Idem
2b2324ee8a Updated to do list 2026-04-20 15:31:29 -04:00
Scott Idem
6c6fccdfb4 Tweaking the colors and timing for the Session Menu in the Launcher 2026-04-20 13:33:54 -04:00
Scott Idem
ef5188aa6d refactor(launcher): remove duplicate session load from menu_session_list
On session click/hover, the menu was calling load_ae_obj_id__event_session
directly AND then navigating via goto(), which re-runs +page.ts and calls
it again. Both fired concurrently on cold cache, causing two identical API
requests for the same session.

Fix: remove the direct load call entirely. The goto() promise is assigned
to ae_promises.slct__event_session_id so the existing #await spinner still
works — it now reflects actual navigation + page.ts load time rather than
a redundant parallel fetch. Remove events_func and ae_api imports (unused).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 13:01:16 -04:00
Scott Idem
c4fdc8efa4 fix(launcher): hidden sessions collapse space, sort by datetime, rename internal-file flag
- menu_session_list: move class:hidden to <li> so fixed-height rows fully collapse
- launcher/+layout.svelte: sort sessions by start_datetime (ascending) instead of name
- Rename hide_content__draft_files → show_content__internal_files (default false);
  remove redundant show_content__draft_files; rename prop hide_draft →
  show_internal_purpose_files in launcher_file_cont; update all 7 call sites and
  the menu_launcher_controls toggle. Now hides admin/draft/outline purpose files
  by default with consistent naming across the flag, prop, and toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 12:49:39 -04:00
Scott Idem
66310adb22 More to do things. 2026-04-19 19:32:43 -04:00
Scott Idem
b94516ce26 fix(idaa): purge IDB when has_cached_session but $ae_loc has no auth
Closes a gap where $ae_loc could be reset externally (sign-out) while
$idaa_loc retained novi_verified within TTL, causing Case 2 to return
early and skip the IDB purge even though the render gate shows Access Denied.
Now Case 2 only preserves the session when $ae_loc also reflects active auth;
inconsistent state falls through to Case 1 (purge).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:53:26 -04:00
Scott Idem
b8e6bcaf03 fix(idaa): strip API calls from all +page.ts/+layout.ts, gate loading in $effect
SvelteKit load functions fire during link prefetch before Novi auth completes;
`if (browser)` guards do not prevent this. Moving all IDAA data fetching into
$effect hooks gated on `novi_verified || trusted_access` closes the IDB
pre-population race across archives, bb/[post_id], and recovery_meetings/[event_id].

Also documents the Auth-Before-Cache rule and per-route status in
AE__Permissions_and_Security.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:49:47 -04:00
Scott Idem
dea599bd9c fix(idaa): move Recovery Meetings load out of +layout.ts, gate $effect on auth
+layout.ts was firing on SvelteKit link prefetch, writing events to IDB
before Novi auth ran. Stripped to thin shell; the existing search $effect
in +page.svelte already handles SWR load+revalidation — just needed an
auth gate (novi_verified || trusted_access) at the top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:15:41 -04:00
Scott Idem
4d5081582f fix(idaa): exempt trusted_access users from IDB purge and BB load gate
Case 1 purge in the layout was firing for manager/trusted users (no UUID),
causing a loop: db_events.event cleared → liveQuery updates → refetch →
store write → Effect 2 re-runs → clear again.

BB $effect was also blocking managers since novi_verified is always false
for non-Novi auth paths.

Both now check trusted_access before gating/purging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:12:02 -04:00
Scott Idem
1381b81bf0 fix(idaa): move BB post loading from +page.ts to $effect in +page.svelte
+page.ts runs before layout effects and fires on SvelteKit link prefetch,
causing private IDAA posts to be written to IDB before Novi auth runs.
Moving to $effect gated on novi_verified eliminates the race entirely —
$effect only runs post-mount, after the layout has verified the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:06:04 -04:00
Scott Idem
686b289bdb fix(idaa): gate BB +page.ts fetch on novi_verified
Without this, +page.ts fires the API call before +layout.svelte
effects run, causing posts to be written to IDB after the purge.
Anonymous users (novi_verified=false) now return early with no fetch.
Cached verified sessions (within TTL) continue to load normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:48:03 -04:00
Scott Idem
6d8f767e45 fix(idaa): add console logs to all IDAA IDB purge paths
Three distinct log messages for each trigger:
- No UUID / no session path
- Novi auth failure (catch block)
- Reset & Retry button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:44:49 -04:00
Scott Idem
61c9a6766d fix(idaa): purge IDAA IDB on no-UUID unauthenticated path
The previous purge only fired inside verify_novi_uuid() catch,
which requires a UUID in the URL. Unauthenticated visits without
a UUID (Case 1 in Effect 2) now also clear posts, comments,
archives, and events from IDB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:43:22 -04:00
Scott Idem
ff4295b24c fix(idaa): also purge db_events on Novi auth failure
Extends the IDB purge from the previous commit to include
db_events.event — covers cached IDAA recovery meeting records.
No module overlap in current client deployments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:37:50 -04:00
Scott Idem
9d8c0e5dd4 Updated to do list for bug fixes related to IDAA. And possibly other areas. 2026-04-19 17:27:28 -04:00
Scott Idem
236a5513ee fix(idaa): purge posts and archives IDB on Novi auth failure
When Novi UUID verification fails (or the manual Reset & Retry is
triggered), clear db_posts.post, db_posts.comment, db_archives.archive,
and db_archives.content from IndexedDB. Prevents private IDAA data
from persisting in the browser after a session ends or auth is denied.

db_events.event intentionally excluded — shared with conference modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:26:34 -04:00
Scott Idem
868f4b3390 Updated he directory path for general agents trash. 2026-04-19 16:55:10 -04:00
Scott Idem
aebbcf5b47 docs: add AI agent bootstrap / quickstart document
Concise onboarding doc covering: project overview, critical rules (IDAA
privacy, no-rm, svelte-check), env/deploy cheat sheet, Svelte 5 runes
patterns, V3 API patterns, naming conventions, real past mistakes, source
layout, and reading order for deeper dives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:52:25 -04:00
Scott Idem
9baffc4407 chore(devops): clean up TODO and remove dead package.json scripts
- TODO: mark BGH file-warning and hide-draft items complete; add detailed
  Dockerfile env-file simplification task (deferred post-April 21 show);
  strip stale completed DevOps entries from the active list
- package.json: remove build:docker:test/prod (never used locally; deploys
  go through remote deploy.sh on Linode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:35:31 -04:00
Scott Idem
898afd9775 fix(files): refine legacy file upload warnings and trusted-access block bypass
- element_input_files_tbl: only block upload for non-trusted users; trusted_access
  users see the same warnings but can still proceed
- element_input_files_tbl: improved warning message wording for .ppt and .doc
- element_manage_event_file_li: minor tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 13:56:19 -04:00
Scott Idem
74e65ea892 feat(files): block upload and show warning for legacy .ppt/.doc file formats
- Set file_list_status to 'blocked_legacy' when any selected file is .ppt or .doc,
  disabling the Upload button until the file is removed
- Show a red banner at the top when upload is blocked
- Add a per-file warning message row in the file table for all legacy/untrusted
  extensions (previously computed but never rendered — only a pink cell highlight)
- Red styling for blocking extensions (.ppt/.doc), yellow for warn-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 13:07:45 -04:00
Scott Idem
1ad3d2030d fix(launcher/files): hide admin-purpose files and fix event_file_id in PATCH body
- launcher_file_cont: add 'admin' file_purpose to hide_draft filter (alongside outline/draft)
- element_manage_event_file_li: remove event_file_id from data_kv passed to update_ae_obj;
  it was being sent in the PATCH body causing 'Unknown column event_file_id in SET' (400)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 13:02:56 -04:00
Scott Idem
721facf7ba fix(locations): auto-load locations on page open; fix session query and POC visibility
- Add +page.ts to trigger load_ae_obj_li__event_location on page load (locations
  were never fetched without a manual trigger)
- Fix ae_comp__event_session_obj_li_wrapper: query used event_location_id_random
  (deprecated index) instead of event_location_id, causing empty session lists
  under each location
- Wire hide__session_poc to pres_mgmt_loc.current.show__session_li_poc_field so
  the Options toggle actually takes effect in the per-location session list
- Also set hide__session_location=true since location is implicit in that context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 11:54:22 -04:00
Scott Idem
a42b49dd50 fix(launcher): auto-set app_mode to native when running in Electron
On a fresh Electron install the events_loc persisted store has no
app_mode value set, causing the native file launch path to fall through
to a browser save dialog. Auto-initialise app_mode='native' in the
launcher layout when is_native is detected so all three modes (default,
onsite, native) continue to work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:51:51 -04:00
Scott Idem
278a40c981 Updated to do list 2026-04-18 18:16:35 -04:00
Scott Idem
5fcf2e86f1 Making things look nicer 2026-04-16 19:48:09 -04:00
Scott Idem
7543bf6ae5 Renamed a directory to be more consistent 2026-04-16 19:15:18 -04:00
Scott Idem
9af5a292b6 Updating to do lists. 2026-04-16 19:11:25 -04:00
Scott Idem
2595664dd1 feat(pres_mgmt): extract session search component + time window filter
- Extract session search form into ae_comp__pres_mgmt_session_search.svelte
  (parallels ae_comp__badge_search.svelte); removes ~145 lines from +page.svelte
- Add time window filter: Clock icon toggle button reveals compact before/after
  selects; trusted users get 3d/7d options; active state highlighted in amber
- Add passes_hide_filter to IDB fast path to mirror API qry_hidden logic and
  eliminate the hidden-session blink on revalidation
- Add passes_time_window applied to both IDB fast path and API results
- Add time window state fields to PresMgmtLocState + pres_mgmt_loc_defaults
- Add contextual warning in "No sessions found" when time filter is active
- badges: hide "Start Here" button for trusted_access users; tweak button shade
- badges: scope placeholder CSS fix to input only (not textarea)
- Add MODULE__AE_Events_PressMgmt_Launcher.md doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 19:01:35 -04:00
Scott Idem
e4265f69af fix(badges): fix stale-Dexie race in font size initialization
The old guard locked on badge ID after the first liveQuery tick. If
Dexie had a cached badge without cfg_json.font_sizes, the guard fired
with no sizes to apply, then blocked the SWR background refresh that
delivered the real saved sizes. Result: font sizes appeared unsaved on
any browser that had visited the badge before sizes were set.

Fix: track the cfg_json string last applied (_font_sizes_applied_cfg)
instead of just the badge ID. Re-applies whenever cfg_json changes on
a background refresh, but skips if local sizes have drifted from the
last apply (user is mid-adjustment — auto-save will sync shortly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:51:18 -04:00
Scott Idem
1df17e68bb fix(badges): lighten placeholder text in create form
Tailwind v4 renders placeholder text too dark on light backgrounds,
making it indistinguishable from real input values. Same scoped CSS
fix already applied to ae_comp__badge_print_controls.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:29:43 -04:00
Scott Idem
deea250a85 fix(badges): add fallback badge_type_code_li when template has no badge_type_list
badge_type_code_li was returning [] when the template had no badge_type_list,
causing the Badge Type field to be hidden entirely in the Staff section.
Add same fallback default as create form and search filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:07:24 -04:00
Scott Idem
4d43fd8a67 fix(badges): move Badge Type field to top of Staff section
Was at the bottom after print position controls — now first item in
Staff Adjustments, before Hide/Unhide Badge, where staff expect it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:03:50 -04:00
Scott Idem
9180384ec1 temp(badges): restrict Copy Link to Administrator + Edit Mode only
Was: Trusted + Edit Mode. Now: Administrator + Edit Mode.
Temporarily restricted for Axonius 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:46:50 -04:00
Scott Idem
75f755c660 temp(badges): restrict Email Link to Administrator + Edit Mode only
Was: visible to everyone pre-print, Trusted+Edit for reprints.
Now: Administrator + Edit Mode only (all three locations).
Temporarily restricted for Axonius 2026 — restore broader access after event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:41:00 -04:00
Scott Idem
4780be7a00 fix(badges): sync create form badge types with search filter; default to attendee
- default_badge_type_code_li now matches ae_comp__badge_search.svelte list
  (attendee, sponsor, staff, guest, volunteer, member, nonmember, test)
- badge_type_code initializes to 'attendee' (In-Person Attendee)
- $effect re-applies preferred default when template badge_type_list loads async,
  falling back to first item if 'attendee' isn't in the template's list
- Remove the blank '-- Select Badge Type --' placeholder option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:32:36 -04:00
Scott Idem
5d6cc4ca35 fix(badges): shorten pending_close Save button label to prevent wrapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:28:54 -04:00
Scott Idem
a68b85e1f1 fix(badges): use direct token classes for field_actions buttons
btn + preset-filled-* renders transparent on gray/surface backgrounds
(Skeleton v4 CSS variable specificity issue — documented in
GUIDE__AE_UI_Style_Guidelines.md §12).

Replace all three buttons in field_actions (Save, Revert, Cancel) with
direct Tailwind token classes: bg-warning-500, bg-error-500,
bg-success-500, bg-surface-200-800 etc. Save button now visibly renders
in amber (dirty), red + pulse (pending_close), green (saved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:19:03 -04:00
Scott Idem
0199c2e2c9 fix(badges): guard unsaved edits — warn on close, error on second close
When a field accordion has unsaved changes and the user tries to close
(X button, same-header click, or switching to another field), we now set
pending_close = true instead of silently discarding.

- Save button turns bright red + animate-pulse with label "Save first (or × to discard)"
- X button turns red with "Discard changes" tooltip
- Field stays open — no data is lost
- Second close attempt (pending_close already true) actually discards
- Saving normally clears pending_close and closes the accordion

WHY: kiosk attendees at a live event were silently losing typed overrides
(professional title, affiliations, etc.) when switching fields mid-queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:07:26 -04:00
Scott Idem
126eb77be2 fix(badges): cancel edit state on field switch, not just on explicit cancel
toggle_field only changed active_field — it never called cancel_field for the
previously open field. Unsaved typed values stayed in edit_full_name_override etc.,
so reopening a field would show the stale typed value and re-apply it to the badge
preview, even though the user had already moved on.

New logic: capture was_open, always call cancel_field for the current field (resets
edit vars + sets active_field = null), then open the new field if it wasn't the one
being closed. Closing a field by re-clicking its pencil now also discards unsaved state,
consistent with the explicit [X] button behavior.

Also: add global placeholder CSS fix to TODO__Agents.md (scoped workaround already
in ae_comp__badge_print_controls; long-term fix belongs in app.css or theme file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:55:22 -04:00
Scott Idem
7733ef8708 fix(badges): auto-focus input after accordion animation + mute placeholder text
Auto-focus: requestAnimationFrame (~16ms) fired before the 185ms accordion
animation ended — input was still 0px/clipped so focus() silently failed.
Changed to setTimeout(210ms) so focus lands after the animation completes.

Placeholder color: placeholders show the current badge value (e.g. 'John Smith')
so without explicit styling they look identical to filled text. Added scoped
CSS rules setting placeholder to gray-400 (light) / gray-500 (dark) so it reads
clearly as a hint rather than existing content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:42:58 -04:00
Scott Idem
a81d65ce7e fix(badges): detect and surface PATCH failure in handle_print_badge
update_ae_obj__event_badge returns false on API failure without throwing,
so the old code always treated a failed PATCH as success — badge printed,
count not saved, page navigated away silently.

Now: check the return value explicitly. On failure — still fire window.print()
(physical print must never be blocked) and still navigate back, but show a
visible red error state ('Printed — count NOT saved (see staff)') and hold
for 4s so a kiosk operator can see it before the loop resets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:36:56 -04:00
Scott Idem
3d81cb5a83 fix(badges): strip milliseconds/Z from datetime before PATCH
MariaDB rejects ISO 8601 with milliseconds ('2026-04-14T20:29:15.784Z').
print_last_datetime and print_first_datetime must be 'YYYY-MM-DDTHH:MM:SS'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:32:07 -04:00
266 changed files with 18330 additions and 8610 deletions

View File

@@ -1,22 +1,15 @@
# Aether Project Brief: aether_app_sveltekit
**Last Updated:** 2026-02-09 22:03:56
**Last Updated:** 2026-05-21 22:25:05
**Current Agent:** mcp_agent
## 🛠️ What I Just Did
Addressed multiple Svelte compiler warnings:
1. Converted ~30 decorative labels to spans (a11y).
2. Applied Svelte 5 untrack() pattern to initial state from props.
3. Fixed CSS scoping for TipTap editor.
4. Added rel="noopener noreferrer" to external links.
5. Commited changes in two atomic batches.
Implemented "Force Sync Location" feature. Optimized file download order with a 4-tier chronological sort (Global > Session > Presentation > Creation Date). Added UI button for onsite operators. Updated project documentation. Verified with npm run check.
## 🚧 Current Blockers
None. Remaining svelte-check warnings (219) require more granular ID/for linking in complex forms.
None.
## ➡️ Exact Next Steps
1. Granular fix for remaining 68 label/ID associations in address/person forms.
2. Systematic application of untrack() for remaining state-from-props warnings.
3. Clean up unused TipTap CSS selectors identified by svelte-check.
User to review changes. Ready for onsite testing/deployment.
---
*Generated by ae_brief*

View File

@@ -31,7 +31,7 @@
1. **Before starting:** Read `documentation/TODO__Agents.md` for active tasks
2. **Before committing:** Run `npx svelte-check` — no exceptions
3. **Commits:** Atomic — one component or fix per commit
4. **Never delete files with `rm`** — move to `~/tmp/gemini_trash`
4. **Never delete files with `rm`** — move to `~/tmp/agents_trash`
5. **Backend coordination:** Use `ae_send_message` or flag changes clearly
---

View File

@@ -163,6 +163,33 @@ npm run deploy:remote:prod
- Private runtime variables are passed via the Docker Compose `.env` file in `aether_container_env/`.
- **Remote deploy**: `aether_container_env/deploy.sh` handles git pull + Docker build + restart on the server. Triggered via `npm run deploy:remote:*`.
### Client-Side Cache & IDB Version Management
The app uses Dexie (IndexedDB) as a local cache for API data (SWR pattern). To prevent
stale cached records from persisting across deploys, two version-tracking systems exist
in `src/lib/stores/store_versions.ts`:
**localStorage store versions (`AE_LOC_VERSION`, etc.)**
Track the schema of persisted Svelte stores (`ae_loc`, `ae_events_loc`, etc.).
Bump when a store's shape changes in a breaking way (field type change, required rename).
The check runs synchronously at module import time, before any store hydrates.
**IDB content versions (`IDB_CONTENT_VERSIONS`)**
Track the content shape of Dexie table rows — specifically what `properties_to_save`
writes to each table. Bump when `properties_to_save` in an object file changes in a way
that makes existing cached rows stale (fields added/removed/renamed, computed field behavior
changed). The `check_and_clear_idb_table()` helper reads a localStorage key per table and
clears the Dexie table on mismatch. Call it from the module's layout on mount.
**When to bump `IDB_CONTENT_VERSIONS`:**
If you change `properties_to_save` in `ae_events__event.ts` (or any other object file),
bump the matching entry here. Failure to do so has historically caused silent "no data"
states that are extremely difficult to diagnose — stale rows pass silently, filter to zero,
and the error looks identical to a genuinely empty result.
Currently wired: `events.event` (via `src/routes/idaa/(idaa)/+layout.svelte`).
All other tables are defined but not yet wired — see the comment block in `store_versions.ts`.
---
## Developing (Local HMR)

View File

@@ -9,11 +9,15 @@
"autofetch",
"Axonius",
"displayplacer",
"elif",
"filelist",
"gsettings",
"onsave"
],
"git.autofetch": true,
"editor.defaultFormatter": "svelte.svelte-vscode"
"editor.defaultFormatter": "svelte.svelte-vscode",
"chat.tools.terminal.autoApprove": {
"npx svelte-check": true
}
}
}

View File

@@ -0,0 +1,33 @@
# Plan: Fix IDAA Jitsi Breakout Links
IDAA Jitsi meetings are embedded in an iframe on the `idaa.org` website. To allow members to "break out" of the iframe (for a better experience on mobile or to use full-tab features), the app provides an "Open Meeting Externally" link.
Currently, this link is generated from `$page.url.href`, which often lacks the `key` (site access key) and `uuid` (Novi identity token) required for Aether's authentication gate, especially if the user has navigated internally within SvelteKit.
## 1. Objective
Ensure all "Breakout" and "Copy Link" actions on the IDAA Video Conferences page include the necessary `key` and `uuid` parameters.
## 2. Implementation Steps
### Step 1: Update `src/routes/idaa/(idaa)/video_conferences/+page.svelte`
- Create a reactive `breakout_url` derived from `$page.url.href`.
- In the derivation logic:
- Instantiate a `new URL`.
- Check if `key` is present in `searchParams`. If missing, pull from `$ae_loc.allow_access` or `$ae_loc.site_access_key`.
- Check if `uuid` is present in `searchParams`. If missing, pull from `$idaa_loc.novi_uuid`.
- Return the resulting `href`.
- Update the following UI elements to use `breakout_url`:
- `copy_meeting_link` function (uses `navigator.clipboard.writeText`).
- "Open in New Tab" anchor tag (`href`).
- "Copy Link" fallback textarea (`value`).
- "Copy Break-out Link" in the Jitsi Tools panel (`value`).
### Step 2: Update `documentation/CLIENT__IDAA_and_customized_mods.md`
- Add a note in the "Authentication: Novi UUID System" or "Iframe Integration" section about the requirement for `key` and `uuid` in breakout links.
- Document that the frontend now automatically re-injects these for external links to ensure persistent session access outside the iframe.
## 3. Verification
- Manually verify the logic:
- If `key` and `uuid` are already in the URL (e.g., initial load), the derived URL should remain unchanged (or correctly deduplicated).
- If they are missing (e.g., after navigating from another IDAA page), they should be added to the generated link.
- Run `npx svelte-check` to ensure no syntax regressions.

View File

@@ -0,0 +1,53 @@
# Plan: Launcher Config UX Refinement (Cohesion & Stability)
The goal of this plan is to address the visual "bouncing", layering overload, and the misplaced close button in the new Launcher configuration modal.
## 1. Dimensional Stability
- **Problem:** Switching tabs causes the modal to resize vertically and horizontally, leading to a "bouncing" feel.
- **Solution:**
- Set a fixed height for the `Launcher_cfg` container (e.g., `h-[750px]`).
- Use `overflow-y-auto` only for the right-hand content pane.
- Ensure the sidebar has a stable width.
## 2. Visual Hierarchy & Layering
- **Problem:** Too many nested backgrounds (Page > Launcher > Modal > Inner Pane > Section Pane > Section Content).
- **Solution:**
- Flatten the background of the main content pane.
- Simplify `Launcher_Cfg_Section.svelte`:
- Remove `shadow-xl` from individual sections.
- Use subtler borders instead of strong "preset-outlined" colors.
- Remove the secondary background (`bg-white/5`) from the section content area.
- Standardize on a single, clean surface color for the right-hand pane.
## 3. The "Centered Close Button" Bug
- **Problem:** A close button is appearing in the middle of the screen.
- **Investigation:**
- Check for absolute-positioned elements in `Launcher_cfg.svelte` or `+layout.svelte`.
- Verify if Flowbite's `Modal` default close button is clashing with internal buttons.
- **Solution:**
- Consolidate all "Close" actions.
- Use the Modal's built-in top-right close button (if available) or a single, well-positioned button in the sidebar.
## 4. Implementation Steps
### Step 1: Update `Launcher_cfg.svelte`
- Set stable dimensions: `h-[750px] max-h-[90vh] w-[1000px] max-w-[95vw]`.
- Remove internal shadows and borders that conflict with the Modal container.
- Clean up the sidebar "Close" button.
### Step 2: Update `Launcher_cfg_section.svelte`
- Simplify the styling to reduce visual clutter.
- Remove `shadow-xl`.
- Use consistent padding and margins.
### Step 3: Update `+layout.svelte`
- Ensure the `Modal` is configured for a stable, large view without default padding issues.
- Verify the `modal_cfg_open` logic.
### Step 4: Add `Launcher_cfg_field.svelte` (Helper)
- Implement a unified field helper to standardize Label/Description/Input layouts across all tabs.
## 5. Verification
- Toggle between all 7 tabs. Verify zero layout shift (height/width remains constant).
- Check appearance in Light and Dark modes.
- Verify "Technical Mode" transitions.

View File

@@ -1211,6 +1211,8 @@ This document provides a reference for the data structures of the core Aether AP
**Source Model:** `Journal_Entry_Base` in `models/journal_entry_models.py`
**UI note:** The current Journals Entry Config modal treats `summary` as metadata, keeps `alert` and `alert_msg` as separate fields, and uses `priority` as a boolean flag while `sort` remains the numeric ordering field.
- `id_random`: `Optional[str]`
- `id`: `Optional[int]`
- `journal_id_random`: `Optional[str]`

View File

@@ -22,6 +22,15 @@ The Aether project is a Svelte and SvelteKit based application, utilizing Tailwi
- **Markdown Parsing:** `marked` library
- **State Management:** Svelte stores, potentially with `liveQuery` from Dexie for reactive IndexedDB interactions.
### 2.1. Journals as the Canonical Frontend Pattern
The Journals module is the current frontend reference for configuration modal structure and journal-entry field semantics. When other docs disagree, Journals should be treated as the implementation target until proven otherwise.
- Entry Config modal sections now follow `Metadata`, `Status & Security`, `Privacy Flags`, `Alerts & Messaging`, and `Admin`.
- `summary` is a first-class journal-entry field and belongs with metadata.
- `alert` and `alert_msg` are separate fields: the flag and its text payload.
- `priority` is a boolean flag in the object model, while `sort` remains the numeric ordering field.
## 3. Module Structure
The Aether project is organized into several modules, categorized as Core, Extended, and Custom.
@@ -77,7 +86,7 @@ Used for more structured client-side data storage, often for caching and offline
Standardized sorting orders are applied across various data lists.
- **Default/General:** `group > priority > sort > updated_on/created_on`
- **Default/General:** `group > priority (flag) > sort > updated_on/created_on`
- **Specific (e.g., Events):** `type > start_date/time > code or name`
## 6. Object Properties and Fields
@@ -95,8 +104,8 @@ These fields are expected to be present in most Aether objects.
- `name`: Display name.
- `enable`: Boolean for active/inactive status.
- `hide`: Boolean for visibility.
- `priority`: Numeric value for ordering.
- `sort`: Numeric value for ordering.
- `priority`: Boolean/tinyint(1) ordering flag used by the object model.
- `sort`: Numeric value for ordering within a priority group.
- `group`: Categorization string.
- `notes`: General notes/comments.
- `created_on`: Timestamp of creation.

View File

@@ -89,13 +89,13 @@ A standardized menu for interacting with objects.
- **Actions:** `create`, `view`, `edit`, `update`, `hide`, `disable`, `delete`, `alert` (message), `archive` (not yet ready).
- **Future Actions:** `copy`, `import`.
- **Sort Options:**
- `[default]`: `group > priority > sort (ASC/DESC) > alert > name`
- `[sort_updated]`: `group > priority > sort (ASC/DESC) > alert > updated_on > created_on`
- `[priority_updated]`: `group > priority > updated_on (ASC/DESC) > created_on`
- `[priority_name]`: `group > priority > name (ASC/DESC) > sort > alert > updated_on > created_on`
- `[name]`: `priority > name (ASC/DESC) > sort > alert > updated_on > created_on`
- `[created_on]`: `priority > created_on (ASC/DESC)`
- `[updated_on]`: `priority > updated_on (ASC/DESC) > created_on`
- `[default]`: `group > priority (flag) > sort (ASC/DESC) > alert > name`
- `[sort_updated]`: `group > priority (flag) > sort (ASC/DESC) > alert > updated_on > created_on`
- `[priority_updated]`: `group > priority (flag) > updated_on (ASC/DESC) > created_on`
- `[priority_name]`: `group > priority (flag) > name (ASC/DESC) > sort > alert > updated_on > created_on`
- `[name]`: `priority (flag) > name (ASC/DESC) > sort > alert > updated_on > created_on`
- `[created_on]`: `priority (flag) > created_on (ASC/DESC)`
- `[updated_on]`: `priority (flag) > updated_on (ASC/DESC) > created_on`
## 2. Pop-ups

View File

@@ -8,21 +8,31 @@ This document outlines the key data structures and their properties used within
These fields are expected to be present in most Aether objects, providing a consistent base structure.
- `id`: Primary key for an object (internal use, often a UUID).
- `id`: Primary key for an object (internal use, often *returned* by the API as a randomized string value in place of the actual DB autonum).
- `id_random`: Randomly generated ID for an object (often used for external exposure or URL parameters).
- `<object_type>_id_random`: Specific random ID for an object (e.g., `person_id_random`).
- `code`: Short, unique identifier.
- `name`: Display name.
- `enable`: Boolean for active/inactive status.
- `hide`: Boolean for visibility.
- `priority`: Numeric value for ordering.
- `sort`: Numeric value for ordering.
- `priority`: Boolean/tinyint(1) ordering flag used by the object model.
- `sort`: Numeric value for ordering within a priority group.
- `group`: Categorization string.
- `notes`: General notes/comments.
- `created_on`: Timestamp of creation.
- `updated_on`: Timestamp of last update.
### 1.2. Special Use Fields
### 1.2. Journal Entry Fields
Journal entries use the shared object fields plus a few content-specific fields that matter in the UI and config modal.
- `summary`: Short entry summary shown in metadata and list contexts.
- `content`: Main body text for the entry.
- `alert`: Boolean flag used to highlight an entry as an alert.
- `alert_msg`: Supporting alert text shown when the alert flag is enabled.
- `private` / `public` / `personal` / `professional`: Visibility and audience flags used by the Entry Config modal.
### 1.3. Special Use Fields
Fields with specific purposes or conditional usage across different object types.
@@ -32,7 +42,7 @@ Fields with specific purposes or conditional usage across different object types
- `passcode`: A password or access code associated with an object.
- `external_id`: An identifier from an external system.
### 1.3. Configuration and JSON Fields
### 1.4. Configuration and JSON Fields
Fields designed to store structured data in JSON format.
@@ -40,7 +50,7 @@ Fields designed to store structured data in JSON format.
- `data_json`: General purpose data for an object, stored as a JSON string.
- `linked_li_json`: A list of linked items, stored as a JSON string.
### 1.4. Special Generated Fields (Client-side)
### 1.5. Special Generated Fields (Client-side)
These fields are generated on the client-side, primarily for facilitating UI logic, such as sorting. They are not typically stored in the backend database.
@@ -48,7 +58,7 @@ These fields are generated on the client-side, primarily for facilitating UI log
- `tmp_sort_2`: Temporary sort field 2.
- `tmp_sort_3`: Temporary sort field 3.
### 1.5. Future Standard Fields
### 1.6. Future Standard Fields
A list of potential future standard fields, often prefixed with `obj_`. These are conceptual and represent planned expansions to the data model.
@@ -58,7 +68,7 @@ A list of potential future standard fields, often prefixed with `obj_`. These ar
Standardized sorting orders are applied across various data lists to ensure consistent presentation.
- **Default/General Sorting:** `group > priority > sort > updated_on/created_on`
- **Default/General Sorting:** `group > priority (flag) > sort > updated_on/created_on`
- **Specific Sorting (e.g., for time-based events):** `type > start_date/time > code or name`
## 3. Data Storage Mechanisms

View File

@@ -47,8 +47,8 @@
- `description`: Longer text description.
- `enable`: Boolean for active/inactive status.
- `hide`: Boolean for visibility.
- `priority`: Numeric value for ordering.
- `sort`: Numeric value for ordering.
- `priority`: Boolean/tinyint(1) ordering flag used by the object model.
- `sort`: Numeric value for ordering within a priority group.
- `group`: Categorization string.
- `notes`: General notes/comments.
- `created_on`: Timestamp of creation.
@@ -76,7 +76,7 @@
## 9. Data Sorting
- **Standard Order:** `group > priority > sort > updated_on/created_on`
- **Standard Order:** `group > priority (flag) > sort > updated_on/created_on`
- **Specific Order:** `type > start_date/time > code or name`
## 10. Local Storage and IndexedDB Keys

View File

@@ -86,6 +86,18 @@ site_access_code_kv: {
}
```
### `x-no-account-id` — Narrow Transport Exception
`x-no-account-id` is a transport-level escape hatch that strips account context before the request leaves the frontend. It is not a permission grant and it is not a replacement for JWT or `x-account-id`.
Use it only when the request truly cannot be made account-scoped. Current legitimate cases should stay narrow:
1. Bootstrap / site-domain discovery before the account is known.
2. Explicit public or guest endpoints that do not have an account context.
3. Helper paths that intentionally need a global-default fallback.
If a request already has a valid account context, prefer `x-account-id` and let the JWT carry session identity. Treat any new `x-no-account-id` use as temporary until it is reviewed and either replaced or justified.
---
## Utility Functions
@@ -113,6 +125,37 @@ Returns `1` if `level_a` is higher, `-1` if lower, `0` if equal. Useful for thre
- IDAA users authenticate via Novi UUID at `authenticated` level or higher.
- A prior agent accidentally exposed IDAA BB data publicly — treat any IDAA exposure as Sev-1.
#### IDAA IndexedDB (IDB) Caching — Auth-Before-Cache Rule
**Root cause discovered 2026-04:** SvelteKit `+page.ts`/`+layout.ts` load functions run *before* layout `$effect` hooks and fire during link prefetch (hover). `if (browser)` guards do NOT prevent this — they only prevent SSR. This means API calls inside these files execute before Novi auth completes, writing private IDAA data to the user's IndexedDB even for unauthenticated sessions.
**The fix — established pattern for all IDAA routes:**
1. **Load/layout `.ts` files = thin shells.** Pass URL params only. No API calls. No `if (browser)` data fetching.
2. **Data loading = `$effect` in `.svelte` files**, gated on:
```svelte
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
```
3. **Three IDB purge paths** in `(idaa)/+layout.svelte` (auth failure, anonymous no-UUID, Reset & Retry button) clear `db_posts`, `db_archives`, and `db_events` tables.
**Auth path matrix:**
| User type | `novi_verified` | `trusted_access` | Can load data? | Purge fires? |
| --- | --- | --- | --- | --- |
| Anonymous / unauthenticated | false | false | No | Yes (Case 1) |
| Novi-verified IDAA member | true | false | Yes | No |
| Manager / trusted access | false | true | Yes | No (Case 3 exemption) |
**Applied to routes (as of 2026-04-19):**
- `idaa/bb/+page.svelte` — `$effect` gate added; `bb/+page.ts` stripped
- `idaa/bb/[post_id]/+page.ts` — stripped; loading handled by trigger in `bb/+layout.svelte`
- `idaa/archives/+page.svelte` — `$effect` gate added; `archives/+layout.ts` stripped
- `idaa/archives/[archive_id]/+page.svelte` — `$effect` gate added; `[archive_id]/+page.ts` stripped
- `idaa/recovery_meetings/+page.svelte` — `$effect` gate already present; `+layout.ts` stripped
- `idaa/recovery_meetings/[event_id]/+page.svelte` — `$effect` gate added; `+page.ts` stripped
**When adding a new IDAA route:** never put API calls in `+page.ts`/`+layout.ts`. Always gate data fetching with the `$effect` pattern above.
### Journals
- Private personal data. Always authenticated. Passcode/encryption features exist.
- Never expose journal content publicly.
@@ -123,6 +166,11 @@ Returns `1` if `level_a` is higher, `-1` if lower, `0` if equal. Useful for thre
- Security model: API key is one layer; JWT + `x-account-id` scoping provides the primary auth.
- Do not introduce new usages. Prefer `PUBLIC_AE_BOOTSTRAP_KEY` for unauthenticated lookups.
### JWT usage guidance
- JWTs are the preferred proof of an established session. Keep them attached to authenticated flows instead of leaning on transport-level bypasses.
- If a route or helper can work with a JWT and an account ID, it should not need `x-no-account-id`.
- If a helper still needs the bypass today, document the reason and add a removal target.
### Email Display
Non-trusted users must never see a full email address. Obscure using:
```typescript
@@ -139,6 +187,12 @@ This pattern lives in `ae_comp__badge_obj_li.svelte` — move to `ae_utils` if n
## Module-Specific Permission Patterns
### Journals — Entry Config Admin Actions
- Entry configuration admin controls are gated to `trusted_access` and above.
- `manager_access` and `administrator_access` see the Delete action, which performs a hard delete.
- `trusted_access` users see Remove instead, which follows disable semantics rather than a hard delete.
- The Admin section is the place for staff notes, enabled/default access state, and destructive entry actions; the template toggle belongs in Metadata, while visibility/audience flags remain separate.
### Events — Badges
| Scenario | Visibility | Print Action | Review Actions |

View File

@@ -0,0 +1,569 @@
# Aether UI/UX — Future Ideas
> Collection of concrete UX improvements for the Aether frontend. Each entry includes
> the rationale, current behavior, proposed change, and implementation notes.
> **Date:** 2026-05-17
---
## IDAA Recovery Meetings
### 1. Guided empty state with active filters — ✅ Implemented 2026-05-18
**Current behavior:** When filters return 0 results, the page shows:
"No recovery meetings found matching your criteria."
The member has no indication whether this is a bug, genuinely no data, or just
overly narrow filters.
**Proposed change:** When filters are active AND the result count is 0, show a
helpful prompt instead of the bare message:
```
No meetings found for these filters.
Try broadening your search or [Clear all filters →]
```
**Implementation notes:**
- Add a `has_active_filters` derived in `+page.svelte` that checks whether any of
`qry__physical`, `qry__virtual`, `qry__type`, or `qry__fulltext_str` is set.
- In the template's `{:else}` block (line ~443), branch on `has_active_filters`:
- `true` → show the guided message + "Clear Filters" button
- `false` → show the existing escape-hatch flow (timed "Refresh Meeting Cache" button
after 8 seconds, since zero unfiltered results always indicates a problem)
- The "Clear Filters" button resets all four filter fields to `null`/`''` and bumps
`search_version` to trigger a fresh unfiltered search.
- Distinct from the `error` state — this is a successful search (`qry__status === 'done'`)
with an empty result set.
**Implemented:** `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte`. `has_active_filters`
derived checks `qry__physical`, `qry__virtual`, `qry__type`, and `qry__fulltext_str`. Empty
state branches on `has_active_filters`: active filters → guided message + "Clear Filters"
button; no active filters → existing escape-hatch flow (timed "Refresh Meeting Cache" after
8 seconds).
---
### 2. Quick-filter chips below the search bar — ✅ Implemented 2026-05-18
**Current behavior:** Members toggle filters via small checkboxes (Virtual, In-person)
and radio buttons (All, IDAA, Caduceus, Family Recovery). These require precise
mouse/tap targeting and scanning several lines of filter UI to discover and use.
**Proposed change:** Add a row of preset chip buttons directly below the search input:
```
[🖥 Virtual] [🏠 In-Person] [🩺 IDAA] [Caduceus] [Family Recovery] [All Types]
```
- Each chip toggles the corresponding filter (`qry__virtual`, `qry__physical`, `qry__type`)
and triggers an immediate search.
- Selected chips get a filled/pressed style; unselected chips are outlined.
- "All Types" is the default selected state (no type filter). Clicking another type
chip deselects "All Types" (radio behavior for the type dimension). Virtual and
In-person are independent toggles (checkbox behavior — can select both).
- The existing checkboxes/radio buttons remain as the underlying state storage
(`$idaa_loc.recovery_meetings.*`). The chips are a convenience layer — they write
to the same store fields and call `handle_search_trigger()`.
**Implementation notes:**
- Place in `ae_idaa_comp__event_obj_qry.svelte` between the search input row and the
current filter rows.
- Optionally hide the existing checkbox/radio filter rows when the chips are present
(or keep both — the checkboxes serve as accessible form controls; the chips are
the primary visual interaction).
- On mobile, chips wrap to a second row naturally with `flex-wrap`.
**Implemented:** `ae_idaa_comp__event_obj_qry.svelte`. Chips replaced the old
checkbox/radio/select UI entirely rather than layering on top. Two chip rows:
Row 1 — My Meetings (first), Virtual, In-Person. Row 2 — All / IDAA / Caduceus /
Family Recovery type chips. Cycling sort button replaces separate sort options
(see item below). Max Results uses a +/ stepper. Sort and max are in a third
row below the chips, inside the same `<form>` constraint.
---
### 3. Language: "Searching..." vs "Loading..."
**Current behavior:** The loading state always shows the same message:
```
🔄 Searching...
```
This appears on initial page load (when the user hasn't typed anything) and after
the user clicks Search or toggles a filter. The word "Searching" implies the user
initiated a search, which is misleading on initial page load — it's a cold cache
load, not an active search.
**Proposed change:** Distinguish the two loading contexts:
| Context | Message |
|---------|---------|
| Initial page load (no filters, no search text) | "Loading meetings..." |
| User clicked Search or toggled a filter | "Searching..." (keep current) |
**Implementation notes:**
- In `+page.svelte` template around line 422, check whether `qry__fulltext_str` is
empty AND no filter checkboxes/radios are active. If so, show "Loading meetings...";
otherwise show "Searching...".
- This is purely a label change — no logic changes needed. The condition can be the
same `has_active_filters` derived from item #1.
- Also update the list component's standalone loading state in
`ae_idaa_comp__event_obj_li.svelte` line 556-558 to use the same distinction.
---
### 4. Filter row collapsing on mobile
**Current behavior:** The query bar has three filter rows (Location checkboxes,
Type radios, Max/Sort selects) plus the search input row and the action button row.
Combined, this takes roughly 200px of vertical space. On mobile — especially inside
the Novi iframe on a phone — meeting cards are pushed below the fold.
**Proposed change:** On viewports below `md` (768px), collapse the Location and Type
filter rows behind a "Filters ▾" toggle. The Max Results and Sort selects stay visible
since they're used frequently. The action buttons (Show Hidden, Create Meeting, Export)
move inside the collapsed panel or stay visible based on available width.
```
[Search input............................] [Search]
[Filters ▾] [Max: 150 ▾] [Sort: Last Updated ▾]
```
Clicking "Filters ▾" expands the panel with Location checkboxes and Type radios.
**Implementation notes:**
- Use a `$state` boolean `show_filters` (session-only, resets on page load).
- Wrap the filter rows in a `{#if show_filters}` block.
- Persist in `$idaa_sess.recovery_meetings.show_filters_expanded` if you want the
state to survive navigation within the module (same tab session).
- The Tailwind `md:` breakpoint works for the collapse trigger: `class:hidden={!show_filters}`
combined with `class:md:block` to always show on desktop.
- Test inside the Novi iframe — Bootstrap v3 may add its own `hidden` behavior on
`md` breakpoints that conflicts with Tailwind's.
---
### 5. Human-readable schedule line on cards
**Current behavior:** The meeting card displays weekdays as a flat, dense span list:
```
Sunday Monday Wednesday Friday
```
The timezone is shown separately as `(America/Chicago)`, and the start time is in
a compact `7:00 PM` format. These three pieces of information are visually separated
and require the member to mentally assemble the schedule.
**Proposed change:** Render a computed one-liner that combines them:
```
🕐 Mondays, Wednesdays, Fridays at 7:00 PM CT
```
- Weekday names are built from the `weekday_*` booleans on the event object.
- "Mondays, Wednesdays" uses the range-joining convention (comma-separated, "and"
before the last item for two days; "Mondays through Fridays" for consecutive spans
of 3+ days).
- Timezone abbreviation is extracted from `timezone` (e.g., `America/Chicago``CT`,
`America/New_York``ET`). A small lookup table handles the common ones; fall back
to the raw timezone string for unknown values.
- If `timezone` is null/missing, fall back to the current flat display — don't
silently drop information.
- Today's meetings could optionally get a subtle "Today" badge or highlight (extra
polish, not required for the initial version).
**Implementation notes:**
- Add a `$derived` in `ae_idaa_comp__event_obj_li.svelte` that computes the schedule
string from the event object's `weekday_*` fields, `recurring_start_time`, and
`timezone`.
- Helper function in `ae_util` for the weekday list → natural language string
(e.g., `['Monday', 'Wednesday', 'Friday']``"Mondays, Wednesdays, and Fridays"`).
- Helper function or small lookup for timezone → abbreviation.
- Fall back to the current flat display when `timezone` is missing to avoid losing
information.
---
### 6. Show result count during search, not just after
**Current behavior:** The result count badge ("Results: 25") only appears inside the
list wrapper component (`ae_idaa_comp__event_obj_li.svelte` line 98-108) when the
visible result list is non-empty. During loading, the user sees only a spinner with
no indication of how many meetings exist or what the search is operating on.
**Proposed change:** Show a result count line at the page level (in `+page.svelte`)
that is always visible once the first search completes:
```
25 of 140 meetings ← after search completes, with result count + total
Searching 140 meetings... ← during initial load (cold cache, no prior result)
0 results for these filters ← empty but filters are active (ties into item #1)
```
**Implementation notes:**
- Lift the count display from the list component to `+page.svelte`, placed between
the query bar (`Comp__event_obj_qry`) and the list wrapper.
- The total count is available from the IDB fast path: after the initial unfiltered
search populates `db_events.event`, the total is `db_events.event.count()` (or
the count of records matching `account_id`).
- The visible count is `event_id_li.length` after search completes.
- Store the last known total in a `$state` variable so it persists across searches
(the total changes infrequently). Refresh the total on the first search after
page load.
- Format: `{visible} of {total} meetings` when filters/search are active;
`{visible} meetings` when browsing all (no active filters).
- During loading with no prior results: show "Loading meetings..." (from item #3)
rather than a count.
---
### 7. "Live Now" and "Starting Soon" indicators
**Current behavior:** Meetings are shown in a static list. To find one happening
now, a member must scan the "When" line of multiple cards and compare the time
to their own clock.
**Proposed change:** Add a high-visibility badge or pulse indicator for meetings
that are currently in progress or starting in the next 15 minutes.
- "LIVE NOW" (Green pulse badge) → if `current_time` is within `[start, start + 1 hour]`.
- "STARTING SOON" (Yellow badge) → if `current_time` is within `[start - 15 min, start]`.
- On the card, move the "Join Zoom" or "Join Jitsi" button to the very top or
make it significantly larger when the meeting is live.
**Implementation notes:**
- Add a `$derived` state `is_live` and `is_starting_soon` to the card component.
- Requires calculating "current time in meeting's timezone" using `Temporal` or
a date helper.
- Ensure the pulse animation is subtle and respects `prefers-reduced-motion`.
---
### 8. Local Timezone Conversion
**Current behavior:** Meetings show their native timezone (e.g., "7:00 PM America/Chicago").
The "Your TZ" line is currently a placeholder and doesn't perform conversion.
**Proposed change:** Automatically detect the member's browser timezone and
show the converted time if it differs from the meeting's native timezone.
```
🕐 7:00 PM CT (8:00 PM ET your time)
```
**Implementation notes:**
- Use `Intl.DateTimeFormat().resolvedOptions().timeZone` to get the user's TZ.
- If `user_tz !== meeting_tz`, perform the conversion.
- If the conversion results in a different day (e.g., late night ET vs early morning Europe),
prefix with "Tomorrow at..." or "Yesterday at...".
---
### 9. Favorites / "My Meetings" — ✅ Implemented 2026-05-18
**Current behavior:** Members scan the full list every time they want to find
their regular weekly meeting.
**Proposed change:** Add a "Star" icon to every meeting card.
- Starring a meeting adds it to a `favorites` list stored in `$idaa_loc`.
- Favorited meetings are pinned to the top of the list by default, regardless
of other sort orders.
- Add a "Favorites" filter toggle in the query bar to show *only* starred meetings.
**Implementation notes:**
- Store as an array of `event_id` strings in `$idaa_loc.recovery_meetings.favorites`.
- Update the `visible_event_obj_li` derived in `+page.svelte` to prioritize
these IDs in the sort logic.
**Implemented:** Star toggle on the `[event_id]` detail page
(`src/routes/idaa/(idaa)/recovery_meetings/[event_id]/+page.svelte`).
"My Meetings" filter chip is first in the filter chip row on the list page.
**Implementation differs from proposal:** favorites stored server-side in a
`data_store` record (code: `idaa_meetings_favorites`) as a UUID-keyed JSON map
rather than in `$idaa_loc` — this means favorites persist across browsers and
devices without Novi write capability. Pinning favorites to the top of the list
was not implemented; the filter chip shows only favorites instead.
---
### 10. "Add to Calendar" (iCal / Google)
**Current behavior:** Members must manually create calendar events if they
want reminders for recurring meetings.
**Proposed change:** Add an "Add to Calendar" dropdown button on the meeting
detail page (and optionally the card).
- Generates a `.ics` file or a Google Calendar URL with the recurring rule
(e.g., "Every Wednesday at 7pm").
- Includes the meeting name, description, and the Zoom/Jitsi link in the location field.
**Implementation notes:**
- Use a helper to generate RFC 5545 `RRULE` strings from the `weekday_*` and
`recurring_pattern` fields.
- Include the `attend_url` in the calendar event description for one-tap join
from phone lock screens.
---
### 11. Geographic Search for In-Person Meetings
**Current behavior:** The only location filter is a binary "Physical" checkbox.
Members must use fulltext search (e.g., "Chicago") to find local meetings.
**Proposed change:** Add a "City/State" search input or a map view.
- When `Physical` is checked, show a "Near [City, State]" input.
- Map view (optional): A toggle to switch from "List" to "Map" view, plotting
meetings on a map using their `location_address_json` coordinates.
**Implementation notes:**
- The `event` table has `location_address_json` which often contains city/state.
- Simple implementation: a city-picker dropdown populated from the distinct
`location_address_json->>'$.city'` values in the current result set.
---
### 12. Prominent "Join" button for virtual meetings
**Current behavior:** On each meeting card, the Zoom or Jitsi join link is rendered
as a small `btn-sm` inside the content area, visually equivalent to other label/value
rows. The "Meeting Details" button at the top of the card is rendered *larger* than the
join link — meaning the primary action for a member who wants to attend a meeting right
now is visually subordinate to a navigation link.
The copy-to-clipboard button for the join link is gated behind `$ae_loc.manager_access`,
so regular members have no easy way to share the link with a sponsee.
**Proposed change:** For virtual meetings, elevate the join button to a full-width
prominent CTA inside the card header area, directly below the meeting name and badges:
```
┌──────────────────────────────────────────────────┐
│ 📅 Monday Night IDAA Discussion 🖥 Virtual │
│ │
│ [ 🎥 Join Zoom Meeting ] ← full-width │
│ [ 📋 Meeting Details ] │
└──────────────────────────────────────────────────┘
```
- The Join button uses `preset-filled` (solid) styling; Meeting Details uses
`preset-outlined` (hollow). This makes the action hierarchy visually clear.
- On mobile especially, a full-width join button is much easier to tap than a
small inline link buried inside label rows.
- Replace manager-only clipboard with a Web Share API button for all members on
virtual meetings: `navigator.share({ title, url })` on mobile triggers the native
OS share sheet. Fall back to clipboard copy on desktop (where `navigator.share`
is often unavailable). This lets members easily send a meeting link to a sponsee.
- Passcode, if present, moves to the Meeting Details page — exposing it in the
list view is unnecessary and clutters the card.
**Implementation notes:**
- In `ae_idaa_comp__event_obj_li.svelte`, move the Zoom/Jitsi attend block from
the `event__content` section up into the `ae_options` div (line ~200), rendered
only when `idaa_event_obj?.virtual` is true and an attend URL exists.
- Keep the existing small label/link in `event__content` as a fallback for when
the prominent button is not shown (non-virtual meetings may still have a URL).
- Web Share: `{#if navigator?.share}` guard; wrap in a try/catch (user cancels
the share sheet throws `AbortError`).
- The Live Now / Starting Soon badges from item #7, when implemented, should also
interact with this button — e.g., a pulsing green border when the meeting is live.
---
### 13. "Today's Meetings" section at the top of the list
**Current behavior:** The meeting list shows all results sorted by the selected sort
order. To find a meeting happening today, a member must scan every card's "When" line
and mentally compare it to the current day and time. There is no at-a-glance view
of what's available right now or later today.
This is distinct from the "Live Now" badge in item #7 (which marks individual cards
after they're already displayed in a long list). This is a dedicated section pinned
above the main results.
**Proposed change:** Add a collapsible "Today" section at the very top of the results
list that shows only meetings scheduled on the current day of the week, sorted by
start time:
```
▼ Today — Sunday, May 17 3 meetings
┌─────────────────────────────────────────────────┐
│ Sunday Serenity Discussion 7:00 AM ET 🔴Live │
│ IDAA Sunday Big Book 2:00 PM CT │
│ Sunday Night IDAA 8:00 PM ET │
└─────────────────────────────────────────────────┘
All Meetings (140)
...
```
- The section is collapsed by default if it's empty (no meetings today).
- Meetings in the "Today" section also appear in the main list below — this is a
quick-access shortcut, not a filter.
- Past meetings (start time has already passed today) are dimmed but still shown;
a meeting may still be in progress.
**Implementation notes:**
- Compute current day-of-week in the browser: `new Date().getDay()` → 0=Sunday, 6=Saturday.
Map to the `weekday_*` boolean fields on the event object (e.g., day 0 → `weekday_sunday`).
- Filter `visible_event_obj_li` (already computed in the list wrapper) for items where
the matching `weekday_*` field is truthy. Sort by `recurring_start_time`.
- Store collapse state in `$idaa_sess.recovery_meetings.today_section_expanded` (session
only; default true so it's visible on first load).
- Renders correctly in the Novi iframe since it's just a filtered sub-list of existing
data — no additional API calls needed.
- If item #7 (Live Now) is implemented, the "Today" section naturally becomes the host
for the live/starting-soon badges, since that's where members will look first.
---
### 14. Data freshness indicator *(low priority — deprioritized)*
**Note:** Meeting records change infrequently — once established, a meeting's schedule,
type, and contact info are typically stable for months or years. The occasional update is
usually minor wording. Surfacing a freshness indicator for data this static would add
visual noise with very little member benefit. The existing error state (item #4 in the
bug fix, distinct "Unable to load meetings") and the escape-hatch cache-reset button
already handle the reliability-concern case. This idea is recorded for completeness but
is not recommended for implementation.
---
### 15. "Confirmed meetings only" default filter
**Current behavior:** All meetings are shown by default, including those with
`status === 'unknown'` (not yet confirmed by IDAA Central Office). The "Not Confirmed
by IDAA" warning badge on those cards is alarming-looking but does nothing to prevent
unverified meetings from dominating the list.
There is currently no filter to hide unconfirmed meetings. Members have no choice but
to see them all.
**Business rationale:** IDAA staff want meeting chairs to submit their meeting info for
verification. Defaulting to "confirmed only" creates a natural incentive: unconfirmed
meetings disappear from the default member view, which encourages chairs to contact
IDAA staff and get their meeting verified. It also gives members a cleaner, higher-
confidence list by default — they're not seeing meetings that may be outdated or
inactive.
**Proposed change:** Add a `qry__confirmed` filter field with three states:
| Value | Behavior |
|-------|----------|
| `'confirmed_only'` (default) | Hide meetings where `status === 'unknown'` |
| `'all'` | Show all meetings, confirmed and unconfirmed |
| `'unconfirmed_only'` | Show only unconfirmed (admin/staff use) |
The filter UI shows a simple toggle in the query bar:
```
[✓ Confirmed Only] ← default, shown as active chip or checkbox
```
When the member switches to "All", the unconfirmed meetings appear with their warning
badge (see item #15 for making that badge useful on mobile).
For trusted/admin users, the default should remain `'all'` so staff can see the full
picture without having to change a setting.
**Implementation notes:**
- Add `qry__confirmed: 'confirmed_only' | 'all' | 'unconfirmed_only'` to
`$idaa_loc.recovery_meetings` defaults, defaulting to `'confirmed_only'`.
Trusted users default to `'all'`.
- Apply the filter in both the IDB fast path (`db_events.event.filter()`) and the
API revalidation secondary filter in `handle_search_refresh`. IDB: check
`ev.status !== 'unknown'` when `qry__confirmed === 'confirmed_only'`. API: same
post-fetch client-side filter.
- Pass `qry__confirmed` to `events_func.search__event` if the API supports a
`status` filter param; otherwise handle it client-side only.
- The `no_results_no_filters` derived (used for the escape-hatch button) should NOT
treat `qry__confirmed === 'confirmed_only'` as an active filter — it's the default
state, not a narrowing choice the member made. Only count it as a filter if the
member explicitly switched it to `'all'` or `'unconfirmed_only'`.
- Add a count badge to the toggle: "Confirmed Only (132 of 140)" so members can see
how many unconfirmed meetings exist without having to switch the filter.
---
### 16. "Not Confirmed" status — inline explanation on mobile
**Current behavior:** Meetings with `status === 'unknown'` show a warning badge:
`⚠ Not Confirmed by IDAA ⚠`. The badge has a `title` attribute with a full explanation
(~2 sentences). Title tooltips are invisible on mobile — tapping the badge does nothing.
Members on phones see a alarming-looking warning with no explanation of what it means
or what they should do.
**Proposed change:** Make the badge tappable. On tap (or hover on desktop), show an
inline explanation panel directly below the badge:
```
⚠ Not Confirmed by IDAA [?]
↓ (on tap)
This meeting has not been confirmed by IDAA Central Office.
Please reach out to the chair for current information.
If this meeting is active, email info@idaa.org to confirm it.
[✕ Close]
```
**Implementation notes:**
- Add a `$state show_unconfirmed_info = false` per card (scoped to the `{#each}` block).
- Replace the `title` attribute with an `onclick` toggle that sets `show_unconfirmed_info`.
- The explanation renders in a `{#if show_unconfirmed_info}` block directly below the
badge row — a simple div with a rounded border and the existing tooltip text.
- The `mailto:info@idaa.org` link in the explanation is already in the tooltip text;
making it a real clickable link here rather than plain text in a tooltip is a direct
improvement for mobile members who want to report a confirmed meeting.
- This pattern also applies to other `title`-only tooltips on the page if they appear.
- Note: with item #15 defaulting to "confirmed only", most members will never encounter
this badge unless they switch to the "All" view. The inline explanation is still worth
implementing for that audience, but the default filter reduces how often it's seen.
---
---
### 17. Cycling sort button — ✅ Implemented 2026-05-18
**Problem:** Three separate sort chip buttons (Last Updated / Name A→Z / Name Z→A) took
too much horizontal space and caused layout bounce as the selected chip changed width.
**Implemented:** Single cycling button in `ae_idaa_comp__event_obj_qry.svelte`.
Clicking advances through `sort_modes` array (Last Updated → Name A→Z → Name Z→A → repeat)
using `$derived` index + `cycle_sort()` function. Button has `min-w-36` to prevent bounce.
Icon changes per mode (fa-clock / fa-sort-alpha-down / fa-sort-alpha-up-alt). A small
fa-redo icon indicates it's a cycling control.
---
### 18. Collapsible "Meeting Info" data store panel — ✅ Implemented 2026-05-18
**Problem:** The `Element_data_store` panel (code: `recovery_meetings_info`) displays
between the filter bar and the meeting results list. Once a member has read it, it
consumes vertical space on every page load and pushes results below the fold, especially
in the Novi iframe on mobile.
**Implemented:** Toggle button wrapping the `<Element_data_store>` in
`src/routes/idaa/(idaa)/recovery_meetings/+page.svelte`. Button shows
"Meeting Info" with a chevron (up = expanded, down = collapsed). Collapse state
persisted in `$idaa_loc.recovery_meetings.ds_info_collapsed` (localStorage) so the
user's preference survives page reloads. New field added to `idaa_local_data_struct`
in `ae_idaa_stores.ts` — no version bump needed (existing users without the field
get `undefined` which is falsy = expanded, the correct default).
---
## Notes
- The fulltext search (`qry__fulltext_str`) searches against the `default_qry_str`
field, which is a server-side composite that already includes contact info, day-of-week
text, meeting type, location, and other metadata. The placeholder text in the search
input is accurate — it genuinely searches contacts and schedule information despite
those fields being stored in separate columns.
- All changes must render correctly inside the Novi iframe context (Bootstrap v3.4.1
CSS conflicts — see `CLIENT__IDAA_and_customized_mods.md` for known issues).
- Mobile testing should cover Android Chrome specifically — the original "no meetings
found" bug disproportionately affected mobile users with intermittent connections.

View File

@@ -0,0 +1,527 @@
# Aether SvelteKit — AI Agent Bootstrap / Quickstart
> **Read this first.** This doc is the fast path to being productive on this project.
> It covers the rules, patterns, and gotchas that matter most.
> Deep dives are in the linked docs at the bottom.
---
## 1. What This Project Is
**Aether** is an event management platform built by One Sky IT (Scott Idem).
This repo is the frontend: **Svelte 5 (runes mode) + SvelteKit v2**.
The backend is a separate repo (`aether_api_fastapi`) — a FastAPI + MariaDB app
running in Docker. The frontend talks to it exclusively via the V3 REST API.
**Key clients:**
- **Conference organizers** — Presentation Management (pres_mgmt), Launcher, Badges
- **Exhibitors** — Leads capture
- **IDAA** — International Doctors in Alcoholics Anonymous (private medical/recovery community)
**Stack at a glance:**
| Layer | Technology |
|---|---|
| Framework | Svelte 5 (runes mode) + SvelteKit v2 |
| Styling | Tailwind CSS v4 + Flowbite (Skeleton UI being phased out) |
| State | `$state`/`$derived` runes + Dexie.js IndexedDB (`liveQuery`) |
| Icons | Lucide (`@lucide/svelte`) |
| Editors | CodeMirror 6 (primary), Edra/TipTap (secondary) |
| Native | Electron app for onsite launcher (`src/lib/electron/electron_relay.ts`) |
| Backend | FastAPI + MariaDB, V3 API (`/v3/crud/`, `/v3/lookup/`) |
| Auth | Custom headers: `x-aether-api-key` + `x-account-id`; JWT Bearer is auto-injected when a session exists |
---
## 2. Critical Rules — Read Before Touching Any Code
### Privacy (Sev-1 class failures if violated)
- **IDAA content is ALWAYS private.** All routes under `/idaa/` require authentication.
A previous AI agent accidentally made IDAA bulletin board data publicly accessible.
This is the single most serious class of mistake on this project. When in doubt — it's private.
- **Journals** are private personal data. Always authenticated.
### File Safety
- **Never use `rm`** to delete files. Move to `~/tmp/agents_trash` instead.
- Never commit `.env` files, API keys, or passwords.
### Before Every Commit
- Run `npx svelte-check` — zero errors, zero warnings. No exceptions.
- Atomic commits: one component or one fix per commit.
### Before Starting Any Task
- Read `documentation/TODO__Agents.md` — it has active tasks, known bugs, and context
about what was recently changed and why.
### V3 API — Never Include the Object ID in PATCH Body Fields
The ID is in the URL. Including it in `data_kv` causes a `400: Unknown column in SET`.
```ts
// WRONG — causes 400 error:
update_ae_obj__event_file({ event_file_id, data_kv: { event_file_id, file_purpose: 'final' } })
// CORRECT:
update_ae_obj__event_file({ event_file_id, data_kv: { file_purpose: 'final' } })
```
---
## 3. Environment & Deploy Cheat Sheet
There are **two separate `.env` systems** — do not confuse them:
| System | File | Controls |
|---|---|---|
| `aether_container_env/.env` | Docker orchestration | Ports, `AE_CFG_ID`, replicas, paths |
| `aether_app_sveltekit/.env.*` | Vite/SvelteKit build | `PUBLIC_*` API vars baked into the JS bundle |
**The 4 commands you run and which env file each uses:**
| Command | Env file read |
|---|---|
| `npm run dev` | `aether_app_sveltekit/.env.local` (Vite dev server, localhost:5173) |
| `npm run build:docker:dev` | `aether_app_sveltekit/.env.dev` (baked into local Docker image) |
| `npm run deploy:remote:test` | `/srv/apps/test_aether_app_sveltekit/.env.test` on Linode |
| `npm run deploy:remote:prod` | `/srv/apps/prod_aether_app_sveltekit/.env.prod` on Linode |
**The `.env.*` files are gitignored** (only `.default` templates are tracked). They must be
placed manually on each server during initial setup. On the workstation you only need
`.env.local` and `.env.dev`. The Linode servers each have exactly one env file for their environment.
**What goes in every SvelteKit env file** (same 8 vars, different values per env):
```env
PUBLIC_AE_API_PROTOCOL=https
PUBLIC_AE_API_SERVER=<api server hostname>
PUBLIC_AE_API_BAK_SERVER=<bak api hostname>
PUBLIC_AE_API_PORT=443
PUBLIC_AE_API_PATH=
PUBLIC_AE_API_SECRET_KEY=<key>
PUBLIC_AE_CRUD_SUPER_KEY=<key>
PUBLIC_AE_BOOTSTRAP_KEY=<key>
PUBLIC_AE_NO_ACCOUNT_ID=No_Account_ID_Here
```
---
## 4. Svelte 5 Runes Mode — Key Patterns & Gotchas
This codebase is **fully Svelte 5 runes mode**. No Svelte 4 syntax.
### The basics
```svelte
<script lang="ts">
// Props — with optional two-way binding
interface Props { count?: number; label: string; }
let { count = $bindable(0), label }: Props = $props();
// Reactive state
let value = $state('');
let upper = $derived(value.toUpperCase());
// Side effects (replaces onMount + $: reactive)
$effect(() => {
console.log('value changed:', value);
return () => { /* cleanup */ };
});
</script>
```
### What NOT to use (Svelte 4 patterns — do not introduce)
```ts
// ❌ No writable() stores for component state
import { writable } from 'svelte/store';
// ❌ No reactive declarations
$: doubled = count * 2;
// ❌ No onDestroy for cleanup — use $effect return instead
onDestroy(() => cleanup());
```
### `$bindable()` vs `$state()`
- Use `$bindable()` when the parent needs two-way binding on a prop.
- Use `$state()` for local component state with no external binding.
### Store reactivity trap (important for `$effect`)
The app uses `svelte-persisted-store` (Svelte 4 contract) for `$ae_loc`, `$ae_api`,
`$ae_sess`, etc. In Svelte 5 `$effect`, reading **any field** of a Svelte 4 store
subscribes to the **entire store**. This means unrelated writes to `$ae_loc`
(e.g. iframe height, SWR reload) will re-trigger your effect. Be conservative about
what you read from these stores inside `$effect` blocks. See `PROJECT__Stores_Svelte5_Migration.md`
for the long-term fix plan.
For search pages specifically, this usually means:
- keep true user preferences in persisted local state
- keep transient triggers, loading flags, and last-executed search keys in session state when possible
- let the page effect schedule the search, but put the duplicate-execution guard inside the search executor so page-load auto-search still runs after hydration
- if the search text or filters are mirrored from localStorage on mount, expect that mount-time writes can re-trigger the effect unless the executor has its own guard
### `{#await}` blocks
```svelte
{#await somePromise}
<LoadingSpinner />
{:then result}
<div>{result}</div>
{:catch error}
<ErrorMessage {error} />
{/await}
```
---
## 5. V3 API Patterns
### SWR (Stale-While-Revalidate) — the standard load pattern
Return cached Dexie data immediately, refresh from API in background.
```ts
async function load_ae_obj_id__my_obj({ api_cfg, obj_id }) {
// 1. Return stale cache immediately (fast)
const cached = await db.my_obj.get(obj_id);
if (cached) my_obj_state = cached;
// 2. Fetch fresh from API in background
_refresh_my_obj_background({ api_cfg, obj_id });
}
```
### Shared/Common Aether object fields
The core fields for almost all Aether objects are:
* id/id_random
* code - string
* name - string
* summary - string
* content - string
* alert - boolean
* alert_msg - text
* priority - boolean
* sort - int
* group - string
* hide - boolean
* enable - boolean
* default_qry_str - special concat string index
* notes - text
* created_on - timestamp
* updated_on - timestamp
### ID convention — never use `_id_random` fields
The V3 API uses random string IDs (e.g. `event_file_id = "aBc123"`). The `*_id_random`
fields are legacy aliases. The integer version of the ID is never returned by the API. Always use the short form:
```ts
// ✅ Correct
event_file_obj.event_file_id
// ❌ Wrong — legacy alias, don't use
event_file_obj.event_file_id_random
```
The short ".id" is also the randomized string, **not an integer** (autonum).
### PATCH — only field values in the body
```ts
// The obj_id goes in the URL (handled by update_ae_obj__* function).
// Only the fields you want to update go in data_kv.
await events_func.update_ae_obj__event_file({
api_cfg: $ae_api,
event_file_id: 'aBc123', // → becomes the URL path param
data_kv: { file_purpose: 'final' } // → only changed fields
});
```
### Auth headers (set automatically by `api.ts`)
```
x-aether-api-key: <PUBLIC_AE_API_SECRET_KEY>
x-account-id: <account_id>
```
**Do not treat `params.key` as an auth bypass.**
Only explicit `x-no-account-id: bypass` means "drop account context".
If `key` is present for business logic, keep `x-account-id` intact.
### Dexie queries — always use the object ID index, not `.get()`
All `db_core` (and other module) Dexie tables define their schema with `id` as the first
field (primary key), followed by the object's string ID (e.g. `person_id`). V3 **never**
returns `id`, so every record stored in Dexie has `id = undefined`. Calling `.get(value)`
does a primary key lookup — it will always miss when passed a string object ID.
```ts
// ❌ Wrong — .get() uses the primary key (id), which V3 never populates:
liveQuery(() => db_core.person.get(person_id))
// ✅ Correct — use .where() on the indexed object ID field:
liveQuery(() => db_core.person.where('person_id').equals(person_id).first())
```
This applies to every table in every module (`db_core`, `db_events`, etc.).
When looking up a single object by its string ID, always use `.where().equals().first()`.
---
## 6. Naming Conventions (snake_case; no camelCase)
| Pattern | Example | Used for |
|---|---|---|
| `ae_comp__*` | `ae_comp__event_badge.svelte` | Route-level components |
| `ae_<module>_comp__*` | `ae_events_comp__session_list.svelte` | Module-scoped components |
| `element_*` | `element_input_files_tbl.svelte` | Reusable library primitives |
| `lq__*` | `lq__journal_obj` | Read-only liveQuery |
| `lqw__*` | `lqw__journal_obj` | Writable form snapshot liveQuery |
| `ae_<module>__<obj>.ts` | `ae_journals__journal.ts` | Object type + functions |
| `db_<module>.ts` | `db_journals.ts` | Dexie instance per module |
The **canonical pattern reference** is the Journals module (`src/lib/ae_journals/`).
When building anything new, model it after Journals.
---
## 7. Mistakes Agents Have Made on This Project
These are real incidents — know them before you start.
1. **IDAA BB exposed publicly** — an agent removed an auth guard from the bulletin board
route. All IDAA content must be behind authentication. Always check route guards when
touching `/idaa/` routes.
2. **`event_file_id` in PATCH body (400 error)** — including the object ID in `data_kv`
when calling `update_ae_obj__*`. The V3 API tries to `SET event_file_id = ...` which
fails because it's a view alias, not a DB column. See Section 2 above.
3. **Bad `.d.ts` declaration silently hid 1368 errors** — a `declare module` in `app.d.ts`
(a script-context file) replaced the entire `@lucide/svelte` type exports instead of
merging. `svelte-check` showed 0 errors, masking real problems. If `svelte-check`
suddenly drops to 0 errors, verify it's not because a bad declaration wiped a module.
4. **Coarse store reactivity loop** — an `$effect` that read `$ae_loc.some_field` was
re-triggering repeatedly because unrelated writes to `$ae_loc` (e.g. SWR config reload)
fired the effect. In Svelte 5, any read of a Svelte 4 store inside `$effect` subscribes
to the whole store. Scope what you read carefully.
5. **`file_purpose == 'admin'` not hidden in Launcher** — the `hide_draft` prop hid
`outline` and `draft` files but not `admin` files. Gaps like this happen when a new
enum value is added to a field without auditing all the places that filter on it.
6. **Deleting files with `rm`** — always move to `~/tmp/agents_trash`. A deleted file may
contain context that's not recoverable from git if it was gitignored.
7. **Dexie `.get()` with a string object ID returns `undefined`** — Dexie `.get(value)`
looks up by the table's **primary key**, which is `id` (the first schema field). The V3
API never returns `id`, so it is always `undefined` in stored records. Passing a string
object ID (e.g. `person_id`) to `.get()` will silently return nothing. Always use
`.where('person_id').equals(person_id).first()` instead. This has caused liveQuery
blocks to always produce `undefined` even when the record exists in Dexie.
8. **Treating `$effect` blocks as auth bypass risks** — a `$effect` inside a child
component cannot bypass a parent `+layout.svelte` auth gate. Children only mount if
the parent calls `{@render children?.()}`. Adding redundant auth guards to `$effect`
blocks that can only run after the parent gate already passed is unnecessary — and
misleads future readers into thinking the parent gate is not sufficient on its own.
The **real** pre-gate risk is `+page.ts` / `+layout.ts`: universal load functions run
before any layout mounts and also fire during SvelteKit link prefetch. Keep those files
clean of data loads in private modules. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md`
"SvelteKit Layout Hierarchy: Security and Execution Order" for the full explanation.
9. **Using query `key` as a proxy for bypass stripped `x-account-id`** — this caused
valid account-scoped requests to lose account context and 403. `key` can be a valid
endpoint/business param, but it is not equivalent to `x-no-account-id: bypass`. Keep
`x-no-account-id` usage narrow and temporary; do not expand it without a documented
allowlist case.
10. **Pre-stringifying `*_json` fields before passing to API wrappers** — the API wrappers
(`api_post__crud_obj.ts` for V3, `api.ts` for legacy CRUD) automatically serialize any
field ending in `_json` (e.g. `cfg_json`, `data_json`). Pass these as plain JS objects.
Pre-stringifying with `JSON.stringify()` before calling the wrapper will double-encode
the value in the legacy path (stringify sees a string and escapes it), and is at best
redundant on the V3 path. Both paths now pretty-print with 2-space indent.
See `GUIDE__AE_API_V3_for_Frontend.md` → section 3C for the full explanation.
11. **Broad Dexie result windows get silently clipped** — if a broad "All" view shows fewer
rows than a narrower filter, check for a page-level limit or an API revalidation step
replacing the local IDB result set. For empty text searches, the full local result set
should drive the display; server refreshes should update cache, not shrink visibility.
12. **Not bumping `IDB_CONTENT_VERSIONS` when changing `properties_to_save`** — this caused
the IDAA Recovery Meetings "no meetings found" bug for approximately one year (20252026).
**What happened:** A deploy changed `properties_to_save` in `ae_events__event.ts`, but no
one bumped `IDB_CONTENT_VERSIONS.events.event` in `store_versions.ts`. Existing users kept
the old stale event records in IndexedDB indefinitely. On the Recovery Meetings page, the
fast path (IDB search) returned those stale records, which all failed the `account_id`
filter and returned 0 results. The API call then either errored silently or was filtered
to 0 by the secondary client-side filter. Critically, the error state and the genuinely
empty state showed the **same** "No meetings found" message — users and staff had no
indication a failure had occurred. The manual Full Reset (via the `?` help panel) always
fixed it, but no one knew why it worked, making the root cause impossible to track down.
**The fix (2026-05-16):** `check_and_clear_idb_table()` in `store_versions.ts` is now
wired in `src/routes/idaa/(idaa)/+layout.svelte` for `db_events.event`. On a version
match it costs one localStorage read. On a mismatch it silently clears the table; the
SWR pattern then repopulates from the API on next load.
**The rule going forward:**
- When you change `properties_to_save` in any `ae_events__*.ts` file (or any other
object file) in a way that makes existing cached records stale — fields added, removed,
renamed, or where a computed field's behavior changes — **bump the matching entry in
`IDB_CONTENT_VERSIONS` in `src/lib/stores/store_versions.ts`**.
- If the table is not yet wired, wire it first (see the wiring instructions in the
`IDB_CONTENT_VERSIONS` comment block in `store_versions.ts`).
- Currently wired: `events.event`. All other tables are not yet wired.
**Also:** Never show the same UI message for both a failed API call and a genuinely empty
result. Always distinguish `qry__status === 'error'` from `qry__status === 'done'` with
0 results in your templates. Silent failures look like data problems and are extremely
difficult to diagnose.
13. **Breaking the API retry loop by returning errors instead of throwing them** — all four
`api_*_object.ts` files (`api_get_object.ts`, `api_post_object.ts`, `api_patch_object.ts`,
`api_delete_object.ts`) use a `.catch()` that returns the error as a value, followed by a
classification block. That block **must throw** for transient network failures (`TypeError`)
so they enter the retry loop. If you change it to `return false`, retries are silently
bypassed for the most common failure mode in hotel/conference WiFi — and nothing warns you.
**What happened (commit a10accfaa, Jan 2026):** A "silence background fetch noise" commit
changed `.catch()` to explicitly `return error`, then the classification block was changed
from a `throw` to `return false`. `TypeError` from `ERR_NETWORK_CHANGED` — the most common
failure on crowded WiFi — stopped retrying. The `retry_count = 5` parameter became dead
code for network errors. Went undetected for ~4 months.
**The retry classification these files must honor:**
- `TypeError` (ERR_NETWORK_CHANGED, WiFi blip) → **`throw`** → enters retry loop with backoff
- `AbortError` where `did_timeout_abort = true` (helper's own timer) → **`throw`** → retries
- `AbortError` where `did_timeout_abort = false` (navigation/unmount abort) → `return false`
- HTTP 400/401/403/422 → `return false` immediately (client errors are deterministic)
- HTTP 5xx → **`throw`** → retries with backoff
**How to verify after any change to the error block:** confirm that a `TypeError` still
produces up to 5 retry attempts with 2s→4s→6s→8s delays before returning false. A single
`return false` after the first network failure means the retry loop is broken.
**Also:** when reviewing these files, check that all four have:
- `ae_auth_error.set()` triggered on 401/403 (shows session-expired banner to the user)
- `timeout = 20000` default (was 60s in PATCH/DELETE until 2026-05-21 — 5-min worst case)
- `did_timeout_abort` flag per attempt (separates helper timeouts from caller aborts)
14. **Account-scoped `liveQuery` trigger firing before bootstrap completes** — components
that load account-specific data via `liveQuery` must not trigger the API fetch until the
bootstrap Sync Effect in `+layout.svelte` has set the real `account_id`.
**What happened:** `element_data_store.svelte` triggered its load when `entry` was falsy.
On a fresh load with no IDB cache, `$ae_api.account_id` was still `null` (bootstrap hadn't
run yet). The `localStorage` scavenge in `api_get_object.ts` then read the stale
`account_id = 1` from a previous dev/demo session and made the API call with the wrong
account. The response was cached in IDB, and the next page load showed the wrong account's
record.
A second failure mode: if IDB _did_ have a cached record from a previous session with a
different account, `liveQuery` returned it as a valid hit (`entry` truthy), so the trigger
never fired to fetch the correct record.
**The fix pattern** for any trigger `$effect` that depends on bootstrapped account context:
```typescript
$effect(() => {
// Use $slct.account_id (non-persisted), NOT $ae_loc.account_id (persisted, stale).
// $slct is initialized to null and set only by the bootstrap Sync Effect, so it
// reliably gates the fetch until bootstrap has completed.
const account_id = $slct.account_id;
const api_ready = !!$ae_api?.base_url;
const entry = $lq__ds_obj as SomeType | null | undefined;
if (!browser || !account_id || !api_ready) return;
// Also re-fetch when IDB holds a record from a different (non-null) account.
// null account_id = global/shared fallback — that is still a valid cache hit.
const entry_is_stale_account =
entry !== undefined &&
entry !== null &&
entry.account_id !== null &&
entry.account_id !== account_id;
if (!entry || entry_is_stale_account) {
trigger = 'load...';
}
});
```
**Why `$slct` not `$ae_loc`:**
`$ae_loc` is a `svelte-persisted-store` — it hydrates from `localStorage` before any
effects run, so its `account_id` may be a stale value from a previous session. `$slct`
is a plain writable store initialized to `null`; the bootstrap Sync Effect is the only
thing that sets it. Until that runs, `$slct.account_id` is `null`, providing a reliable
gate. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md` → "Bootstrap Race" for the Dexie-side
context.
15. **`tmp_sort_*` comparators written descending instead of ascending** — `build_tmp_sort()` encodes `priority=true` as `'0'` and `priority=false` as `'1'`, designed for **ascending** sort so priority items appear first. Writing a JS `.sort()` comparator as `b.localeCompare(a)` (descending) inverts the encoding and sends priority items to the bottom.
Found in journals (2026-06), IDAA recovery meetings fast-path and API re-sort (2026-06), and as a Dexie anti-pattern in BB post comments.
```ts
// ❌ Wrong — descending puts priority=false ('1') before priority=true ('0')
list.sort((a, b) => (b.tmp_sort_1 ?? '').localeCompare(a.tmp_sort_1 ?? ''));
// ✅ Correct — ascending matches build_tmp_sort encoding
list.sort((a, b) => (a.tmp_sort_1 ?? '').localeCompare(b.tmp_sort_1 ?? ''));
```
**Companion Dexie trap:** `collection.reverse().sortBy('tmp_sort_*')` — Dexie ignores a collection-level `.reverse()` when `.sortBy()` is called. The sort is always ascending. To reverse the result, call `.reverse()` on the returned array after `await`. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md` → `build_tmp_sort` section.
**Exception — legacy `ae_events__event.ts` encoding:** `ae_events__event.ts` (and `ae_events__event_session.ts`) do NOT use `build_tmp_sort`. They use `priority ? 1 : 0` (priority=true→`'1'`), which requires **descending** sort to put priority items first. `ae_events__event_presentation.ts` DOES use `build_tmp_sort` (it overrides the generic encoding in its `specific_processor`). Do not apply the ascending rule to raw event or session sorts until those modules are migrated to `build_tmp_sort`.
16. **Service worker without `skipWaiting()` + `clients.claim()` silently serves stale code to long-lived tabs** — The default SvelteKit service worker template does NOT include these calls. Without them, a new SW installs in the background but waits in a **"waiting"** state until every tab running the old version is closed before it activates. Users who leave a page open all day (especially IDAA members in the Novi iframe on idaa.org) run old buggy JS indefinitely after a fix is deployed.
**Symptom that should trigger this check:** Bug reports from users that developers cannot reproduce. Developers constantly refresh and open/close tabs — the new SW activates immediately for them. End users with persistent tabs never get it.
**The fix** (already applied to `src/service-worker.js` as of 2026-06-03):
```js
self.addEventListener('install', (event) => {
event.waitUntil(addFilesToCache());
self.skipWaiting(); // activate immediately, don't wait for tabs to close
});
self.addEventListener('activate', (event) => {
event.waitUntil(deleteOldCaches());
self.clients.claim(); // take control of all open tabs right away
});
```
**Trade-off:** A tab mid-session gets new JS without a page reload. For a read-heavy app like IDAA (browsing meetings) this is harmless. For a form-heavy app the risk is higher — weigh accordingly.
---
## 8. Source Layout (Quick Reference)
```text
src/lib/
ae_api/ — API helpers (V3 preferred)
ae_core/ — Account, User, Person, Site, hosted files
ae_events/ — Events, sessions, presenters, badges, locations, files
ae_journals/ — Journals (canonical/frontier model — copy patterns from here)
ae_idaa/ — IDAA custom module (PRIVATE — always authenticated)
elements/ — Reusable UI: V3 field editor, data store, CodeMirror, QR scanner
electron/ — Native Electron bridge (electron_relay.ts)
stores/ — ae_stores.ts, ae_events_stores.ts, ae_idaa_stores.ts
src/routes/
/core/ — Admin (accounts, people, sites, users)
/events/[id]/
/(pres_mgmt)/ — Presentation management
/(launcher)/ — Event launcher (kiosk display)
/(badges)/ — Badge printing
/(leads)/ — Exhibitor leads
/journals/ — Journals
/idaa/ — IDAA module (PRIVATE)
/hosted_files/ — File management
```
---
## 9. Reading Order for Deeper Dives
Start here, then go deeper as needed:
| What you need | Read |
|---|---|
| Active tasks + known bugs | `documentation/TODO__Agents.md` ← always first |
| Dev workflow + commit rules | `documentation/GUIDE__Development.md` |
| V3 API reference | `documentation/GUIDE__AE_API_V3_for_Frontend.md` |
| Dexie / liveQuery patterns | `documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md` |
| Svelte 5 patterns + pitfalls | `documentation/GEMINI__Svelte_and_Me.md` |
| Permissions + auth levels | `documentation/AE__Permissions_and_Security.md` |
| Electron / native launcher | `documentation/PROJECT__AE_Events_Launcher_Native_integration.md` |
| Store migration plan | `documentation/PROJECT__Stores_Svelte5_Migration.md` |
| Exhibitor Leads module | `documentation/MODULE__AE_Events_Exhibitor_Leads.md` |
| Naming conventions | `documentation/AE__Naming_Conventions.md` |

View File

@@ -3,7 +3,7 @@
**Client:** International Doctors in Alcoholics Anonymous (IDAA)
**Module Path:** `src/routes/idaa/`
**State Stores:** `src/lib/stores/ae_idaa_stores.ts`
**Last Updated:** 2026-03-09 (Novi UUID verification upgrade)
**Last Updated:** 2026-05-18 (Default limit and stepper update)
---
@@ -31,6 +31,17 @@ IDAA is a private membership organization for physicians in recovery. They use t
IDAA's Aether instance is embedded as an **iframe inside their existing Novi-powered website** (`idaa.org`). Novi is their external Association Management System (AMS) — it handles membership records and authentication. Aether receives the member context via URL parameters on iframe load.
### Breakout Links and Iframe Persistence
Members often need to open Jitsi meetings outside the Novi iframe (e.g., for full-screen features or on mobile). These are referred to as **Breakout Links**.
- **The Problem:** SvelteKit client-side navigation within the iframe often drops "bootstrap" query parameters like `?key=...` (site access key) and `?uuid=...` (Novi identity token).
- **The Requirement:** When a member breaks out of the iframe into a new browser tab, these keys **must** be present in the URL. Without them, the member will hit the site-domain gate or the IDAA auth gate and see "Access Denied."
- **The Solution:** The Video Conferences page uses a derived `breakout_url` that proactively re-injects the missing `key` (from `$ae_loc.allow_access`) and `uuid` (from `$idaa_loc.novi_uuid`) before generating the external link.
**Example Breakout URL:**
`https://client.oneskyit.com/idaa/video_conferences?uuid=...&key=...&room=...`
---
## Architecture: Composite Module
@@ -85,8 +96,8 @@ src/routes/idaa/
│ │ └── [event_id]/
│ │ ├── +page.svelte # Meeting detail page — renders view OR edit based on session flag
│ │ └── +page.ts
── video_conferences/ # Jitsi video conference integration
└── jitsi_reports/ # External Jitsi reporting
── video_conferences/ # Jitsi video conference integration
└── jitsi_reports/ # Jitsi meeting activity log report (trusted_access only)
```
> **Note:** Recovery Meetings has **two UI entry points**:
@@ -113,21 +124,22 @@ IDAA members do not log in through Aether — they log in through Novi (idaa.org
> **Security note (2026-03-09):** The iframe HTML files previously also passed `email` and `full_name`
> via URL params. These were unverifiable claims that could be spoofed via URL. They have been removed.
> The SvelteKit layout now fetches verified identity directly from the Novi API.
> The SvelteKit layout now verifies identity via the Aether server-side Novi proxy — the Novi API
> call originates from the server, not the member's browser.
> See "Iframe Integration" → "Novi UUID Verification Flow" below.
### Verification Flow (`(idaa)/+layout.svelte`)
When a `uuid` param is present in the URL, the layout performs an **async Novi API call** to verify:
When a `uuid` param is present in the URL, the layout performs an **async call to the Aether server-side endpoint** (`GET /v3/action/idaa/novi_member/{uuid}`), which proxies to Novi server-to-server:
1. The UUID actually exists in Novi's system (prevents fake/crafted UUIDs)
2. Gets verified name and email directly from Novi — these can't be forged via URL
2. Gets verified name and email — these can't be forged via URL
3. Sets `$idaa_loc.novi_uuid`, `$idaa_loc.novi_email`, `$idaa_loc.novi_full_name`
4. Sets `$idaa_loc.novi_verified = true` on success
A `novi_verifying` UI state prevents the "Access Denied" screen from flashing during the API round-trip.
**All or nothing:** If the Novi API key is not configured, or the verification call fails, access is denied. There is no URL-param fallback.
**All or nothing:** If the Novi API key is not configured on the site, or the verification call fails, access is denied. There is no URL-param fallback.
**Required `site_cfg_json` fields:**
```json
@@ -148,22 +160,26 @@ This section documents the exact way Aether uses the Novi API for the IDAA integ
- **All-or-nothing policy:** If the Novi API key is not configured or the verification call fails, the Novi-based access path is denied. The layout explicitly prevents child routes from rendering while verification is in-flight to avoid flashing "Access Denied".
- **Rate limits (Novi API):** 20 calls/second · 600 calls/minute · 100,000 calls/day. The layout handles 429 responses with a 10-second flat backoff and one retry. If the retry also returns 429, access is denied and a "Reload / Retry" button is shown. The 5-minute TTL cache on successful verification prevents repeated calls during normal use.
- **Rate limits (Novi API):** 20 calls/second · 600 calls/minute · 100,000 calls/day. The Aether backend handles 429 responses; the frontend receives a `429` and retries once after 10 seconds. The 12-hour TTL cache on successful verification (Redis server-side + `$idaa_loc` client-side) prevents repeated calls during normal use. A `503` (Novi unreachable) is auto-retried once after 3 seconds before surfacing an error to the user.
### Verification Flow (implementation)
1. The IDAA iframe loads Aether pages with a `?uuid=<uuid>&iframe=true` param.
2. When the `uuid` param is present the IDAA layout performs an authenticated GET against the Novi customers endpoint:
2. When the `uuid` param is present the IDAA layout calls the Aether server-side proxy:
```js
// simplified
fetch(`${api_root_url}/customers/${uuid}`, {
fetch(`${aether_api_url}/v3/action/idaa/novi_member/${uuid}`, {
method: 'GET',
headers: { 'Authorization': `Basic ${api_key}` }
headers: {
'x-aether-api-key': api_key,
'x-account-id': account_id
}
})
// Aether calls Novi server-to-server; member's browser IP is never in the Novi call path.
```
3. On success the layout uses the returned JSON to build a display name and normalized email, then writes these values to the IDAA store and marks verification success.
3. On success (`200`), the layout reads `data.full_name` and `data.email` from the response and writes them to the IDAA store, marking verification success.
4. The layout then determines a target Novi permission level (`authenticated`, `trusted`, `administrator`) by checking configured UUID lists (`novi_trusted_li`, `novi_admin_li`) and upgrades the Aether session only if the Novi-derived level is higher than the current global level.
@@ -171,9 +187,9 @@ fetch(`${api_root_url}/customers/${uuid}`, {
### Key `site_cfg_json` fields and where they are used
- **`novi_idaa_api_key`**: Base64-encoded Basic auth token provided by Novi. Required for the verification request. Accessed in code as `$ae_loc.site_cfg_json.novi_idaa_api_key` and passed in the `Authorization: Basic <key>` header. If missing, Novi-based access is denied.
- **`novi_idaa_api_key`**: Base64-encoded Basic auth token provided by Novi. Used by the Aether **server** to authenticate against Novi — the frontend never touches the key itself. The frontend checks only for its *presence* in `site_cfg_json` as a guard meaning "IDAA is configured for this site". If missing, Novi-based access is denied.
- **`novi_api_root_url`**: Optional API root (defaults to `https://www.idaa.org/api`). Used to form the verification URL.
- **`novi_api_root_url`**: Optional Novi API root (defaults to `https://www.idaa.org/api`). Read by the Aether server, not the frontend.
- **`novi_admin_li`**: Array of UUIDs treated as administrators for IDAA. Merged into `$idaa_loc.novi_admin_li` during layout initialization and used to set `administrator` level.
@@ -183,6 +199,17 @@ fetch(`${api_root_url}/customers/${uuid}`, {
- **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails.
- **`jitsi_exclude_uuids`**: (optional) Array of Novi UUIDs to exclude from Jitsi Reports.
This is the canonical staff/test filter. UUIDs are matched case-insensitively against
`final_participants[].novi_uuid` when present. Example: `["uuid-1", "uuid-2"]`.
- **`jitsi_known_meetings`**: (optional) Array of meeting names / room names to keep in the report.
When this list is non-empty, only matching `room_name` values are shown. Matching is
case-insensitive.
- **Legacy fallback:** `jitsi_exclude_names` is still honored for older configs, but it should be
migrated to UUIDs.
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
### Stores / runtime fields set by verification
@@ -203,12 +230,32 @@ These fields are read elsewhere in the IDAA UI to enable flows for verified user
### Security notes and operational guidance
- The previous implementation leaked `email` and `full_name` via URL params — this was removed because those values are unauthenticated and can be spoofed.
- The API key is sensitive — keep it only in site config and do not expose it in client-side code or public repositories.
- The verification request uses Basic auth with the provided `novi_idaa_api_key` (already Base64-encoded by Novi) — treat the token like a password.
- If Novi changes their customer API shape, update the layout parsing (display name/email normalization) and this documentation.
- The API key is sensitive — keep it only in site `cfg_json` and do not expose it in client-side code or public repositories. The key is read and used exclusively by the Aether backend; it is never sent to the browser.
- If Novi changes their customer API shape, update `app/methods/idaa_novi_verify_methods.py` in the backend (display name/email normalization) and this documentation.
If you need a compact checklist for re-creating this flow in another integration, ask and I will add a small runbook with exact request/response field mappings.
### ~~Planned: Server-Side Novi Verification~~ ✅ Implemented (2026-05-19)
**Problem solved:** The previous client-side Novi API call originated from the member's browser.
Hotel/conference WiFi, VPNs, corporate/hospital networks, and Cloudflare IP reputation filtering
could block these calls and produce false "Access Denied" for legitimate members.
**Solution implemented:** A FastAPI endpoint proxies the Novi call server-to-server
(Aether → Novi), with Redis caching. Members' browser IPs are no longer in the call path.
**Endpoint:** `GET /v3/action/idaa/novi_member/{uuid}`
- Standard Aether auth headers (`x-aether-api-key`, `x-account-id`)
- Server reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json`
- Redis cache: `idaa:novi_member:{uuid}` — 4-hour TTL, only 200s cached
- `404` results never cached (recently-joined members not incorrectly denied)
**Frontend:** `verify_novi_uuid()` in `(idaa)/+layout.svelte` now calls this endpoint with
standard Aether headers. The `novi_idaa_api_key` is still checked for presence in
`site_cfg_json` as a proxy for "is IDAA configured for this site" (server holds the key itself).
**Full API spec:** `GUIDE__AE_API_V3_for_Frontend.md` §12.
### Permission Levels (Ascending)
| Level | Condition | Access |
|---|---|---|
@@ -275,7 +322,12 @@ This ensures that OSIT staff with `super` or `manager` roles retain full access
### Access Gate (`(idaa)/+layout.svelte`)
The inner layout blocks ALL rendering if the user is not authorized:
- `novi_verifying = true` → "Verifying identity..." spinner
- `novi_verifying = true` → "Verifying identity..." spinner (message updates during retry)
- `verify_error_type === 'rate_limited'` → yellow "Identity Verification Unavailable" panel with:
- **Try Again** — calls `handle_verify_retry()` (respects retry_count, waits 10 s before re-calling Novi)
- **Clear Cache & Reload** — clears IDB + localStorage + sessionStorage, then reloads
- **Full Reset** — same clear but also navigates to `/` with `invalidateAll`
- `verify_error_type === 'api_error'` → same yellow panel (API returned non-2xx, not a rate limit)
- Verification failed or no UUID → "Access Denied" error page
- Access check runs before any child routes render
@@ -365,27 +417,87 @@ Recovery Meetings reuses the Aether Events object to represent AA recovery meeti
### Search Filters
Members can filter meetings by:
- **Fulltext search** — name, location
- **Physical** — in-person meetings
- **Virtual** — online meetings (Zoom, Google Meet, etc.)
- **Meeting type** — specific meeting format categories
- **Fulltext search** — name, location, day of week, contacts (debounced 250ms; uses SWR pattern)
- **Virtual** — online meetings (Zoom, Jitsi, other)
- **In-person** — physical location meetings
- **Meeting type** — IDAA / Caduceus / Family Recovery
- **My Meetings** — star toggle; shows only meetings the member has starred (favorites)
Search is debounced (250ms) and uses the standard Aether SWR pattern.
**Sort options:** Last Updated (default), Meeting Name AZ, Meeting Name ZA.
**Empty state behavior:**
- Zero results with active filters → "No meetings found for these filters" + "Clear all filters" button
- Zero results with no filters → bare message shown, then after 8s a "Refresh Meeting Cache" escape hatch appears (clears IDB and re-fetches from API — indicates a stale-cache problem, not a real empty set)
Search uses the standard Aether SWR pattern (IDB cache returned immediately, then API refreshes in background).
### Search Architecture — What Is and Isn't Searched
The fulltext search runs against the `default_qry_str` field (backend-computed, contains:
`id_random`, type, name, description, timezone, recurring pattern/text, location text).
The fulltext search runs against the `default_qry_str` field (backend-computed STORED GENERATED
column, contains: `id_random`, type, name, description, timezone, recurring pattern/text,
location text, **contact name and email**).
**Contact names and emails are NOT currently searchable.** The `contact_li_json` field is a
JSON longtext — MariaDB cannot efficiently substring-search it directly. The backend already
has a `contact_li_json_ext` (STORED GENERATED, indexed) column to work around this, but it
has not yet been added to the searchable fields whitelist in the API.
**Contact names and emails ARE searchable via the API path.** `default_qry_str` includes
contact data, so the API `lk_qry` LIKE search on that field covers contacts automatically.
**Pending fix (tracked in TODO__Agents.md, 2026-04-08):**
- Backend: add `contact_li_json_ext` to the event object searchable fields whitelist
- Frontend: add `contact_li_json_ext` as an OR condition in `search__event()`, and update
the local IDB fast-path filter to parse `contact_li_json` for instant cache results
**IDB fast-path gap:** The local cache (Dexie) fast-path returns all cached meetings without
text filtering — users see the unfiltered list immediately, then the API result (with contacts
filtered) replaces it after the background refresh completes. The IDB path does not parse
`contact_li_json` for instant local text matching.
**Known history (2026-05-19):** Contact search appeared broken due to two issues now resolved:
1. The backend STORED GENERATED columns (`default_qry_str`, `contact_li_json_ext`) had stale
values; forced a rebuild via fake updates on each event record.
2. The recovery meetings page secondary filter was re-running text matching against response
fields — silently dropping results that matched only via `default_qry_str` (e.g. by contact
name, since that field may not appear in the response body). Fix: removed text re-filtering
from the secondary filter (type / physical / virtual OR-logic only).
**Remaining enhancement (tracked in TODO__Agents.md):**
- Add `contact_li_json_ext` to the IDB fast-path filter in `search__event()` and the recovery
meetings page so contact matches appear instantly from cache, not only after API refresh.
### Sort Encoding — Events Use Legacy (Not `build_tmp_sort`)
`ae_events__event.ts` builds `tmp_sort_1` with the **legacy encoding**: `priority ? 1 : 0`
(priority=true → `'1'`). This is the **opposite** of `build_tmp_sort` (priority=true → `'0'`).
| Module | Encoding | Correct comparator |
| --- | --- | --- |
| `ae_events__event.ts` (Recovery Meetings) | Legacy: `priority=true→'1'` | **Descending** `b.localeCompare(a)` |
| `ae_events__event_session.ts` | Legacy: `priority=true→'1'` | **Descending** `b.localeCompare(a)` |
| `ae_events__event_presentation.ts` | `build_tmp_sort` (overrides legacy in `specific_processor`) | **Ascending** `a.localeCompare(b)` |
| Journals, Posts, Archives | `build_tmp_sort` | **Ascending** `a.localeCompare(b)` |
**Do not apply the `build_tmp_sort` ascending rule to raw event or session sorts** until
`ae_events__event.ts` is migrated (tracked in TODO__Agents.md under IDB Sort rollout).
### Search Trigger — Use `$slct.account_id`, Not `$ae_loc.account_id`
The recovery meetings search `$effect` gates on `$slct.account_id` (set only by the bootstrap
Sync Effect, non-persisted). Do NOT change this back to `$ae_loc.account_id`.
**Why:** `$ae_loc` is a persisted store that hydrates from localStorage on page load. Its
`account_id` may be stale from a previous session (e.g., a dev/demo account_id left behind).
Using it as the gate fires the API call with the wrong account before bootstrap has run,
producing either a 403 or wrong-account data. `$slct.account_id` is null until bootstrap
sets it — a reliable gate. See mistake #14 in `BOOTSTRAP__AI_Agent_Quickstart.md`.
### My Meetings (Favorites)
Members can star meetings to build a personal "My Meetings" list. The star toggle appears:
- On each card in the meeting list (`ae_idaa_comp__event_obj_li.svelte`)
- On the meeting detail page nav bar (`[event_id]/+page.svelte`)
Favorites are stored in the `data_store` table (code: `idaa_meetings_favorites`, scoped to the
IDAA account). The record's `json` field holds `{ [novi_uuid]: [event_id, ...] }` — one shared
record per account containing all members' favorites. This means:
- Favorites persist across browsers and devices (server-side)
- Does **not** write to `ae_event` rows (avoiding the `ON UPDATE current_timestamp()` side effect)
- Known last-write-wins race condition if two members toggle simultaneously — acceptable for ~1000 members
- Pre-created DB records: ID 150 (`gaTKSVPagFj`, account_id=1, dev/demo), ID 151 (`knJh8zhyKT0`, account_id=13, live IDAA)
The star button uses inline styles (not `.btn`) to avoid Bootstrap v3 box-model overrides in the iframe.
### Edit Form — Sections and Key Fields
@@ -446,6 +558,136 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store.
---
## Module 5: Jitsi Reports
**Route:** `/idaa/jitsi_reports/`
**Access:** `trusted_access` or `novi_verified` — same gate as the rest of `(idaa)/`
**Data source:** `activity_log` table — `jitsi_meeting_event` and `jitsi_meeting_stats` log types
**Library function:** `qry__jitsi_report()` in `src/lib/ae_reports/reports_functions.ts`
An admin/staff reporting tool that aggregates raw Jitsi activity logs into human-readable meeting sessions. It is **not** a member-facing page — IDAA members do not see it.
**Reminder:** this page now filters staff by Novi UUID and can whitelist known meeting names from site config.
### View Modes
Two display modes, toggled via a button in the page header:
| Mode | Description |
| --- | --- |
| **Grouped by Room** (default) | One collapsible section per `room_name`. Each section contains a compact table: Date / Time / Duration / Attendees / Participant List. Mirrors the output of the offline Python script (`create_jitsi_report.py`). |
| **Flat List** | Original card-per-session accordion layout. Better for drilling into event timelines and raw participant lists. |
Both modes use the same filtered data set — switching views does not reset filters.
### Dark Mode / Surface Safety
The page now uses explicit page and row surfaces so dark mode does not collapse into white-on-white
text in either the regular app or the Novi iframe.
### Filters
| Filter | Default | Logic |
| --- | --- | --- |
| **Min. Participants** | 2 | Minimum `real_participant_count` to display a session. Used as the only size filter. |
| **Room Name** | edit mode only | Case-insensitive substring match against `room_name`. Hidden unless AE global edit mode is on. |
| **From / To** | last 60 days / today | Date range applied to `start_time`. "To" date includes the full end of day. |
A "Reset Filters" button appears whenever any filter is non-default.
In edit mode, two extra toggles appear:
- **Show excluded IDs** — temporarily include the UUIDs listed in `jitsi_exclude_uuids`
- **Show all meetings** — temporarily ignore `jitsi_known_meetings`
An "Active Exclusions" panel below the filter bar shows the currently applied Novi UUID exclusions
and known meeting-name whitelist values. Each list is collapsible so the page stays compact.
### Staff / Meeting Filtering
**Problem:** Staff/test accounts and one-off test rooms distort the reports.
**Site config keys:**
```json
{
"jitsi_exclude_uuids": ["uuid-1", "uuid-2"],
"jitsi_known_meetings": ["IDAA-BIPOC-Meeting", "IDAA-Sunday-Meeting"]
}
```
**How it works:**
1. The page reads `$ae_loc.site_cfg_json?.jitsi_exclude_uuids` and excludes matching participants by Novi UUID.
The UUID comes from the Jitsi log `url_params.uuid` field. `g_uuid` is the meeting/group UUID and is not used here.
2. If a participant record does not include a UUID in the activity log, it is left visible; UUIDs are used whenever available.
3. `real_participant_count = real_participants.length` drives filters, exports, and the per-meeting attendee count.
4. Room-level unique participant counts are computed from Novi UUIDs when present, with display-name fallback only for UUID-less records.
5. If `$ae_loc.site_cfg_json?.jitsi_known_meetings` is non-empty, only meetings whose `room_name` matches one of the listed names are shown.
6. The Room Name filter is only shown when global edit mode is enabled.
**Temporary stopgap:** the report also hides these staff display names through the same UUID-exclusion toggle until the long-term logging fix lands:
`Scott I.`, `Brie P.`, `Michelle V.`
**Note:** matching is case-insensitive on the stored `room_name` / meeting name.
### Summary Stats
Shown above the meeting list when data is loaded. Stats reflect the **filtered + exclusion-applied** view:
- **Meetings Shown** — count of sessions passing all filters
- **Total Participants** — sum of `real_participant_count` across all shown sessions
- **Avg Duration** — mean session duration (HH:MM:SS)
- **Total Duration** — sum of all session durations (HH:MM:SS)
In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available).
Each meeting instance keeps the full participant list visible; the **Copy names** button is edit-mode only so staff can grab the list for follow-up reports without exposing extra controls to normal viewers.
### Caching / Load Behavior
The page now reads cached `activity_log` rows from IndexedDB first, renders that result immediately,
then refreshes from the API in the background. That keeps the report usable even when the network
round-trip is slow.
Both the cache path and the API refresh now page through the matching activity-log set in
`created_on DESC` order with a 1000-row page size before building the report. That avoids the old
"first 500 rows" behavior that could hide newer sessions if the log table grew large.
The report page keeps the newest session first in both the flat list and the grouped-by-room view;
grouped room rows are also sorted newest session first within each room.
### Jitsi URL Builder
Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`.
### Video Conferences → Reports Link
Trusted Access users now get a footer link on the Video Conferences page that jumps back to the Jitsi Reports page. It preserves the current iframe context so the staff workflow stays inside the Novi embed.
**Future idea:** make that link include a `room=` query param for the current meeting so Jitsi Reports can auto-filter to that meeting instance, and have Reset clear that param again.
### Export
CSV and JSON export buttons in the page header export the **currently filtered + exclusion-applied** data set.
### Room Name Fragmentation
The same logical meeting can appear as multiple rooms (e.g. `IDAA-BIPOC-Meeting`, `IDAA-BIPOC-Meeting-2026`, `IDAA-BIPOC-Meeting-March-31`) because the Jitsi URL builder appends a date suffix to generate unique per-session room names. In grouped view, these appear as separate groups. A future normalization pass (strip trailing date suffixes) could optionally merge them — not implemented yet.
### Data Flow
```text
activity_log table
└── qry__jitsi_report() # reports_functions.ts — fetches + aggregates by meeting_id
└── MeetingReport[] # { meeting_id, room_name, start_time, final_duration,
# final_participants, final_participant_count, events }
└── jitsi_reports/+page.svelte
├── apply exclusion list → real_participants / real_participant_count
├── apply filters → meetings_filtered
├── derive grouped view → Map<room_name, MeetingReport[]>
└── render flat or grouped
```
---
## State Management (`ae_idaa_stores.ts`)
Four stores manage all IDAA state:
@@ -463,18 +705,36 @@ Stores Novi auth context and per-submodule query settings:
novi_jitsi_mod_li: string[] // Jitsi moderator UUIDs
archives: { enabled, hidden, limit, offset, edit__archive_obj, edit__archive_content_obj }
bb: { enabled, hidden, limit, offset, edit__post_obj, edit__post_comment_obj }
recovery_meetings: { qry__fulltext_str, qry__physical, qry__virtual, qry__type, qry__limit, edit__event_obj }
bb: { enabled, hidden, limit, offset, edit__post_obj, edit__post_comment_obj,
qry__enabled, qry__hidden, qry__limit, qry__offset, qry__order_by, qry__order_by_li }
recovery_meetings: {
qry__enabled, qry__hidden, qry__limit, qry__offset,
qry__fulltext_str, qry__physical, qry__virtual, qry__type,
qry__order_by, qry__order_by_li,
qry__favorites_only, // true = show only starred meetings (My Meetings filter)
edit__event_obj // null or event_id string when edit form is open
}
}
```
### `idaa_sess` (sessionStorage — cleared on tab close)
### `idaa_sess` (in-memory only — resets on page load)
UI state per submodule:
```typescript
{
archives: { qry__status, show__modal_edit__archive_id, show__modal_view__archive_id, obj_changed }
bb: { qry__status, show__modal_edit__post_id, show__modal_view__post_id, obj_changed }
recovery_meetings: { qry__status, show__modal_edit, show__modal_view, attend_platform, obj_changed }
archives: { qry__status, show__modal_edit__archive_id, show__modal_view__archive_id,
show__modal_edit__archive_content_id, show__modal_view__archive_content_id, obj_changed }
bb: { qry__status, edit__post_obj, show__inline_edit__post_obj, show__modal_edit__post_id,
show__modal_view__post_id, obj_changed }
recovery_meetings: {
qry__status, // null | 'loading' | 'done' | 'error'
qry__fulltext_str, // session-only copy (separate from persisted loc copy)
search_version, // incremented to trigger a new search cycle
edit__event_obj, // null | event_id — controls edit form visibility
show__modal_edit, show__modal_view,
show__modal_edit__event_id, show__modal_view__event_id,
attend_platform, // 'Zoom' | 'Jitsi' | null — platform selected in virtual attend section
obj_changed
}
}
```
@@ -535,6 +795,10 @@ https://assets-staging.noviams.com/novi-core-assets/css/c/idaa/idaa.css — Boo
- `<section>` and heading elements may get unexpected margins/padding from Bootstrap's typography reset
- Class names `.field-input` and `.field-label` (used in the v2 edit form's scoped `<style>` block)
also exist in `idaa.css`'s date picker — Svelte's scoped attribute selector wins, but be aware
- In iframe widths near Tailwind `sm`, avoid hiding critical button labels behind breakpoint classes
and do not depend on color-only active states; Bootstrap's `.active`/button styling can make the
selected state nearly invisible unless the control uses an obvious fill/ring change plus
`aria-pressed`
**Mitigation:** The iframe CSS conflicts existed before v2 and are not new. The v2 form uses the
same Skeleton/Tailwind component classes as the rest of the app. Avoid using bare `<section>`,
@@ -582,6 +846,7 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
| Bulletin Board | ❌ None | Priority — most sensitive module |
| Recovery Meetings | ✅ Substantial | `tests/idaa_recovery_meeting_edit.test.ts` — form render, field interactions, PATCH payload verification (all sections), real backend save, creation linkage (Novi UUID in POST body) |
| Video Conferences | ❌ None | Jitsi complexity, lower priority |
| Jitsi Reports | ❌ None | Admin-only tool; lower privacy risk than member modules |
**Pending:** BB Post and Post Comment creation linkage tests (pattern established in Recovery Meetings test).
@@ -634,4 +899,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
---
**Document Status:** ✅ Current
**Last Verified:** 2026-04-07updated for Novi UUID triple-linkage enforcement, staff editing rules, Contact 1 convention, test coverage
**Last Verified:** 2026-06-03Recovery Meetings: documented legacy `tmp_sort_1` encoding for events (requires descending sort, not ascending); documented `$slct.account_id` gate pattern for search trigger; noted service worker `skipWaiting`/`clients.claim` requirement for long-lived IDAA iframe sessions (root cause of user-reported loading failures that could not be reproduced in dev)

View File

@@ -19,8 +19,15 @@ Required for any non-public data (Journals, Badges, Users, etc.).
* **Header:** `x-account-id: <account_id>`
2. **Administrative Bypass**: For authorized scripts needing global access.
* **Header:** `x-no-account-id: bypass`
* **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
3. **Token Access**: Provide a **JWT** in the query string.
* **Query Param:** `?jwt=<token>`
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
* Only explicit `x-no-account-id: bypass` should strip account context.
> [!NOTE]
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.
> [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
@@ -89,6 +96,26 @@ The primary way to retrieve data.
* **Endpoint:** `POST /v3/crud/{obj_type}/search`
* **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects.
#### Sorting with `order_by_li`
Pass a JSON object as the `order_by_li` query parameter to sort results:
```ts
// ?order_by_li={"filename":"ASC","created_on":"DESC"}
const params = new URLSearchParams({
order_by_li: JSON.stringify({ filename: 'ASC', created_on: 'DESC' })
});
```
> [!IMPORTANT]
> **`order_by_li` only accepts columns from the raw base table** — not view-only join columns.
>
> Some object types (e.g. `event_file`) have enriched views that JOIN other tables to expose convenience fields like `event_presenter_family_name`. These are available in search results when using `?view=alt`, but they **cannot** be used in `order_by_li`. Attempting to sort by them silently drops those sort keys (the query proceeds without them).
>
> If you need to sort by a joined field, sort client-side on the returned list.
>
> **Columns safe to sort on for `event_file`:** any field in the `event_file` table itself — `filename`, `title`, `extension`, `created_on`, `updated_on`, `sort`, `enable`, etc.
### C. POST Create / PATCH Update
Modify data in the system.
* **Endpoints:**
@@ -99,6 +126,22 @@ Modify data in the system.
* **Header:** `x-ae-ignore-extra-fields: true`
* **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.
#### `*_json` field serialization — do NOT pre-stringify in route/component code
The frontend API wrappers (`src/lib/ae_api/api_post__crud_obj.ts` for V3, `src/lib/api/api.ts` for legacy CRUD) automatically serialize any field whose name ends in `_json` (e.g. `cfg_json`, `data_json`) before sending. They pretty-print with 2-space indent via an internal `serialize_json_field_pretty()` helper.
**Pass `*_json` fields as plain JS objects from routes and components.** The serialization layer handles the rest.
```ts
// ✅ Correct — pass as plain object; V3 wrapper serializes it
await update_ae_obj__site({ site_id, data_kv: { cfg_json: { jitsi_token_endpoint: url } } });
// ❌ Wrong — double-encodes the JSON string (the wrapper would stringify an already-stringified value)
await update_ae_obj__site({ site_id, data_kv: { cfg_json: JSON.stringify({ jitsi_token_endpoint: url }) } });
```
The V3 wrapper (`api_post__crud_obj.ts`) only serializes when `typeof value === 'object'`, so it will not double-encode a plain string. The legacy wrapper (`api.ts`) stringifies unconditionally, so pre-stringifying there **will** produce double-encoded JSON. In both cases, the right answer is to pass the raw object and let the layer handle it.
### D. ID Fields in Responses (Vision ID Convention)
> [!IMPORTANT]
@@ -243,7 +286,7 @@ When seeding new lookup data (e.g., adding timezones in bulk):
## 5. Event File Data Retrieval (Hosted Files)
Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file`). The Hosted File itself is a metadata record for binary content (files), which is accessed via separate Action endpoints (e.g., `/v3/action/hosted_file/download`). This API endpoint provides metadata about the associated hosted file. To retrieve this additional metadata:
Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file`). The Hosted File is a metadata record for binary content, accessed via dedicated Action endpoints. To download an event file use `/v3/action/event_file/{event_file_id}/download` — not the hosted_file endpoint directly (each endpoint only accepts its own ID type). To retrieve hosted file metadata alongside an event file record:
* **Endpoint:** `GET /v3/crud/event_file/{event_file_id}`
* **Query Parameter:** Add `inc_hosted_file=true`
@@ -258,6 +301,48 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file
* `hosted_file_size` (string - in bytes)
2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc.
### Direct Download Links (Shareable / External)
Event files can be downloaded without standard auth headers using one of two bypass mechanisms. This is useful for generating shareable links for staff or external recipients.
- **Method:** `GET`
- **Path:** `/v3/action/event_file/{event_file_id}/download`
> [!WARNING]
> **Breaking change (2026-06-10):** This endpoint now requires an `event_file_id`. Previously it accepted `hosted_file_id` or `archive_content_id` and resolved the chain automatically — that cross-resolution has been removed. Pass the correct ID type for the endpoint you are calling. If you were routing downloads through `/v3/action/hosted_file/{hosted_file_id}/download` as a workaround, switch to this endpoint using `event_file_id`. *(Remove this note after ~2026-06-24.)*
#### Auth bypass options
| Query param | Value | When to use |
|---|---|---|
| `?key=<account_id_random>` | Any valid account random ID | Staff sharing within a known account context |
| `?site_key=<site_access_key>` | The site's `access_key` value | Public or semi-public distribution tied to a specific site |
Either param replaces the need for `x-aether-api-key` / `x-account-id` headers, so the URL is self-contained and works in a plain browser tab or `<a href>` link.
#### Optional params
| Query param | Description |
|---|---|
| `filename` | Override the download filename (min 4 chars). Useful for giving files clean display names. |
#### Building a shareable link
```ts
// Build a self-contained download URL for staff/external use
function makeDownloadUrl(eventFileId: string, accountId: string, displayName?: string): string {
const base = `https://dev-api.oneskyit.com/v3/action/event_file/${eventFileId}/download`;
const params = new URLSearchParams({ key: accountId });
if (displayName) params.set('filename', displayName);
return `${base}?${params}`;
}
```
The endpoint supports byte-range requests (`Range` header), so it works correctly for in-browser media streaming as well as direct file downloads.
> [!NOTE]
> The `?key=` bypass verifies only that the account ID exists — it does not confirm the file belongs to that account. It is appropriate for internal staff tools. For publicly distributed links, prefer `?site_key=` which ties access to a specific site's configured key.
---
## 6. Hosted File Actions: Convert & Clip (Frontend Notes)
@@ -278,22 +363,55 @@ These helper endpoints let the frontend request small server-side transformation
- Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
- Auth: standard V3 headers
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
- Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize.
- For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints.
- Returns 400 on synchronous failure; returns 202 when scheduled successfully.
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
- Defaults to stream-copying (fast); set `reencode=true` to force H.264 or `scale_down=true` to resize.
- Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion.
- Returns 400 on synchronous failure; 202 when scheduled successfully.
Frontend guidance:
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
- Prefer `?background=true` for large inputs to avoid request timeouts. For heavy or batch workloads use a queued job pattern instead.
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026)
## 8. Email Send Action
Send a transactional email via the Aether API.
- **Method:** `POST`
- **Path:** `/v3/action/email/send`
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
**Request body:**
```json
{
"from_email": "noreply@example.com",
"from_name": "Example App",
"to_email": "user@example.com",
"to_name": "Alice Smith",
"subject": "Your login link",
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
"body_text": "Visit ... to log in.",
"cc_email": null,
"bcc_email": null
}
```
**Query params:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `test` | bool | `false` | Simulate send without delivering |
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
@@ -401,7 +519,7 @@ or:
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found.
**Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
@@ -421,7 +539,7 @@ Check a user's current password without changing it.
```
or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
---
@@ -474,10 +592,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
| Parameter | Type | Default | Description |
|---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. |
| `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`).
> [!IMPORTANT]
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.
**Magic link URL format (default `key_param_name`):**
```
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
```
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.
**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.
---
@@ -503,7 +630,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
---
## 9. Event Exhibit Tracking Export (Leads Export)
## 10. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -571,10 +698,67 @@ const url = URL.createObjectURL(blob);
---
## 10. Troubleshooting 403 Forbidden
## 12. IDAA: Server-Side Novi Member Verification
Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP.
- **Method:** `GET`
- **Path:** `/v3/action/idaa/novi_member/{uuid}`
- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`)
### Request
| Parameter | Location | Required | Description |
|---|---|---|---|
| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) |
### Response on success (`200 OK`)
```json
{
"data": {
"verified": true,
"full_name": "Alice S.",
"email": "alice+member@idaa.org"
}
}
```
- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent.
- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`).
### Error responses
| Status | Meaning | Frontend action |
|---|---|---|
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
### Migration from direct Novi call
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
| Direct Novi result | This endpoint returns | Frontend state |
|---|---|---|
| `200` with identity data | `200` | `verified` |
| `200` with no identity data | `404` | `denied` |
| `404` | `404` | `denied` |
| `429` | `429` | `'rate_limited'` |
| Network error / Novi 5xx | `503` | `'api_error'` |
### Caching
Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt.
---
## 11. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct.
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
3. Verify the record actually belongs to the account ID you are sending.
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
5. Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
6. If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.

View File

@@ -157,26 +157,58 @@ This layout hides `.badge_back` in `@media print` — only the front face prints
---
### Epson — Fan-Fold / Label Printer
### Epson ColorWorks C3500 — Fan-Fold Label Printer
**Status:** Not yet tested. Section to be filled in after testing.
**Card stock:** 4" × 6" fan-fold paper label stock
**Layout code:** `badge_4x6_fanfold`
**Status:** Configured. First live use: Axonius Adapt DC — June 9, 2026.
Common Epson models used for fan-fold name badge stock: TM-T88 series, C3500, LX series.
Fan-fold stock is typically 4" × 3" or 4" × 6" paper labels.
The C3500 is a color inkjet label printer — it prints continuous fan-fold paper stock,
not individual cards. Badges are separated along the perforation after printing.
#### Physical Setup
- Connect via USB or Ethernet
- Load 4" × 6" fan-fold stock per Epson instructions
- The C3500 is single-sided — only the front face prints. Back section is suppressed in CSS.
- The badge has a lanyard hole punch: 5/8" × 1/8", centered, 1/4" from the top.
Most fan-fold stock for badge use includes a pre-punched lanyard slot — verify stock matches.
#### Driver
- Epson ColorWorks C3500 CUPS driver available from epson.com (ColorWorks section)
- On Linux/CUPS: install the provided PPD and add the printer at `http://localhost:631`
- Set default paper size to **4" × 6"** in CUPS
- Print a test page from CUPS before going live
#### Chrome Print Settings (C3500)
| Setting | Value |
|---|---|
| Destination | Epson C3500 (CUPS name) |
| Paper size | 4 × 6 in (set in CUPS driver) |
| Margins | **None** |
| Background graphics | On |
| Pages | 1 (single-sided) |
#### CSS Layout
Fan-fold badges would use a layout sized to the specific label stock.
A new CSS layout file will need to be created per stock size if not already present.
Naming convention: `badge_layout_epson_[model]_[size].css`
The C3500 uses the `badge_4x6_fanfold` layout. CSS file:
`src/lib/ae_events/badges/css/badge_layout_epson_4x6_fanfold.css`
#### Setup Notes
*(To be filled in after testing — cover: driver source, CUPS setup, paper size, Chrome settings)*
Created 2026-05-15 for Axonius Adapt DC. Key specs:
- `badge_front` 4" × 6", portrait orientation
- `badge_header` max-height 1.5in
- Lanyard hole: 5/8" × 1/8", centered, 1/4" from top
- `@page { size: 4in 6in; margin: 0; }` set in the print page dynamically
- `.badge_back` suppressed in `@media print` (single-sided)
#### Known Behaviors
*(To be filled in after testing)*
- Same Chrome margin rules apply: **Margins → None** prevents URL/date header clipping
- Firefox honors `@page { size: 4in 6in }` for PDF proofing — use it to verify layout
- Fan-fold stock separates along the perforation — no cutting needed, but verify the
perforation lands outside the badge content area
---

View File

@@ -0,0 +1,132 @@
# Guide — Aether Events: Onsite Runbook
This guide covers the human-centric logistics and "In the Heat of the Moment" support for onsite event operations.
---
## Badge Printing
Aether badge printing uses the browser's native `window.print()` — no special software or print
server needed.
### Kiosk Station Setup
- **Browser:** Use **Chrome (Chromium)** for all kiosk stations.
- **Settings:** Set Margins to **None**. Enable **Background Graphics**.
- **Mode:** Use normal browser sessions (not Incognito) to allow PWA caching.
### Printer Reference: Zebra ZC10L (PVC)
- **Stock:** 3.5" × 5.5" PVC cards.
- **Orientation:** Cards face-up, landscape in the hopper.
- **Single-Sided:** Only the front face prints; the back section is hidden via CSS.
- **Layout code:** `badge_3.5x5.5_pvc`
### Printer Reference: Epson ColorWorks C3500 (Fan-Fold)
- **Stock:** 4" × 6" fan-fold paper label stock.
- **Single-Sided:** Only the front face prints; the back section is hidden via CSS.
- **Layout code:** `badge_4x6_fanfold`
- **Lanyard hole:** Pre-punched 5/8" × 1/8" slot at top center — verify stock matches.
- **First live use:** Axonius Adapt DC, June 9, 2026.
### Printing Workflow
1. **Search:** Find the attendee by name or QR scan in the Badges module.
2. **Review:** Open the print page and confirm the layout looks correct.
3. **Print:** Click **Print Badge**. `print_count` increments automatically.
4. **Handoff:** Verify the card print quality before handing it to the attendee.
---
## Exhibitor Leads (Lead Retrieval)
Exhibitors use a PWA (Progressive Web App) to scan badges and capture leads.
### Exhibitor Support Workflow
1. **Booth Lookup:** Help the exhibitor find their booth in the Leads landing page.
2. **Sign-In:** Assist with the **Shared Passcode** or individual **Licensed User** login.
3. **App Install:** Encourage them to "Add to Home Screen" (iOS) or click the Install button (Android/Chrome) for offline stability.
4. **Scanning Demo:** Show them the **Rapid Scan** mode. Remind them that attendees must have `allow_tracking = true` on their record to be scanned.
### Managing Licenses
- License counts are managed in the **Manage** tab (Admin or Shared Passcode only).
- If an exhibitor needs more staff slots, update the `license_max` in the Exhibit record.
---
## Speaker Ready Room (SRR)
... (rest of the file) ...
The SRR is the central hub for content management and presenter support.
### SRR Practice Stations
Stations mirror the session room setup exactly:
- Same Mac laptop model and adapter/dongle configuration as the podiums.
- Projector and screen (where possible).
- Launcher running in **Native** mode — ensures verification matches the podium experience.
### Staffing Roles
| Role | Access Level | Typical Tasks |
|---|---|---|
| **OSIT Staff** | `trusted_access` | Manage devices, monitor via VNC, deep troubleshooting. |
| **Client Staff** | `authenticated_access` | Upload files, view session lists, assist presenters. |
| **Presenter** | `authenticated_access` | Self-upload via QR link (if enabled). |
### SRR Workflow — Day-of-Show
1. **Check-in:** Staff looks up the presenter's session in Presentation Management.
2. **Upload:** File is uploaded to the presenter/session record.
3. **Verification:** Staff opens the file on a practice station to confirm rendering.
4. **Launcher Sync:** File propagates to the podium. Use **Force Sync Location** in the Launcher config if immediate full-room caching is needed.
5. **Proceed:** Presenter walks to the room; the podium kiosk already has the file cached.
---
## Onsite Operation (Managing Parallel Rooms)
### SRR Overview Page
The Pres Mgmt overview (`/events/[id]/pres_mgmt`) is the "Command Center":
- Monitor file status per session.
- Filter by location and time block to stay ahead of active sessions.
### Per-Room Monitoring
- Use **VNC or RustDesk** to monitor all podium screens in real time from the SRR.
- Confirm "Native Sync" status chip in the bottom-left of the Launcher is green/idle before sessions start.
### Session Transitions
- **Timing:** Ideally, sessions show/hide based on `datetime_start`.
- **Manual Control:** In looser schedules, use Launcher controls to manually select the current session.
---
## Pre-Show Checklist
### 12 Weeks Before
- [ ] Event created with correct dates and timezone.
- [ ] `mod_pres_mgmt_json` configured for client needs.
- [ ] Locations (rooms) created and named.
- [ ] Sessions created, assigned to locations, and timed.
- [ ] Launcher devices (`event_device`) registered with correct codes.
- [ ] Device-to-location assignments confirmed.
### Day Before (SRR Setup)
- [ ] Mac laptops at podiums booted; Electron app running.
- [ ] Each podium confirms it loaded the correct room's Launcher.
- [ ] SRR practice stations confirmed (matching hardware).
- [ ] Run **Force Sync Location** on all podiums to pre-cache all day-1 content.
- [ ] VNC/RustDesk connections established to all podiums.
### Day of Show
- [ ] Confirm all session times are accurate before the first block.
- [ ] Monitor SRR queue and verify every file on a practice station.
- [ ] Check VNC wall to ensure all podiums are online and synced.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Session not in Launcher | Datetime wrong or Location unassigned. | Verify session metadata in Pres Mgmt. |
| File uploaded but missing | Polling lag or attached at wrong level. | Wait 30s; check if file is at Session vs Presenter level. |
| File opens slowly | Not in native cache yet. | Check "Native Sync" chip; use Force Sync in config. |
| File won't open | Corrupt upload or missing Mac codec. | Test on SRR station; convert or re-upload. |
| Drifted schedule | Room timing shifted. | Use Launcher controls to manually select the active session. |
| `lock_config` resets changes | Remote config is forced. | Edit the master `mod_pres_mgmt_json` in Event Settings. |
| Move laptop to new room | Hardware reassignment. | Update `location_id` in `event_device` record; restart Electron. |

View File

@@ -19,7 +19,7 @@
## 2. Commit Policy
- **Atomic Commits:** One component or one logic fix per commit. Do not batch unrelated changes.
- **Safety:** Use `~/tmp/gemini_trash` for file removal; never use `rm` directly on source files.
- **Safety:** Use `~/tmp/agents_trash` for file removal; never use `rm` directly on source files.
- **Secrets:** Never commit `.env`, API keys, or passwords.
## 3. Coordination (The Handshake)

View File

@@ -25,13 +25,54 @@ let lq__obj = $derived(
- Cold start (IDB empty) + non-blocking API writes: If you mount a component before data is written to IDB, `liveQuery` may run against an empty DB. The API write will populate IDB later, but sometimes a chain of dependent queries (e.g., presentations -> presenters) won't all rerun in the order you expect. The symptoms you described — session shows after one refresh, presenters only after a second — are consistent with either (a) queries recreated in the wrong order or (b) dependent store values being set only after some subscriptions are already created.
### Bootstrap Race: Account-scoped Loads Before `account_id` Is Set (2026-06)
Account-scoped `liveQuery` triggers can fire before `+layout.svelte`'s bootstrap Sync Effect
has propagated the real `account_id`. Two failure modes:
1. **IDB empty:** fetch runs with `account_id = null`. The `localStorage` scavenge in
`api_get_object.ts` reads the stale value from a previous session — possibly a different
account — and caches that wrong record into IDB.
2. **IDB has a stale record:** `liveQuery` returns a cached record from a different account as
a valid hit, so the trigger condition (`!entry`) is never true and the correct record is
never fetched.
**Rule:** Gate any trigger `$effect` that loads account-scoped data on `$slct.account_id`,
not `$ae_loc.account_id`. `$slct` is a plain writable store (not persisted), initialized to
`null` and set _only_ by the bootstrap Sync Effect. `$ae_loc` is a persisted store that
hydrates from `localStorage` before effects run and may carry a stale `account_id`.
Also treat a non-null, non-matching `account_id` in an IDB record as a cache miss:
```typescript
$effect(() => {
const account_id = $slct.account_id; // null until bootstrap Sync Effect runs
const api_ready = !!$ae_api?.base_url;
const entry = $lq__obj as SomeType | null | undefined;
if (!browser || !account_id || !api_ready) return;
// null account_id on a record = global/shared fallback — still a valid hit.
const entry_is_stale_account =
entry !== undefined && entry !== null &&
entry.account_id !== null &&
entry.account_id !== account_id;
if (!entry || entry_is_stale_account) {
trigger = 'load...';
}
});
```
See `BOOTSTRAP__AI_Agent_Quickstart.md` → Section 7, entry 14 for the full incident writeup.
### Critical Discovery (2026-02-26): The "try_cache: false" Bug
**Symptom:** Nested data (e.g., Session → Presentations → Presenters) requires multiple manual refreshes to display on cold-start, even when using blocking loads.
**Root Cause:** Two interconnected issues in nested data loaders:
1. **Disabled caching in nested loads**: Parent loads were passing `try_cache: false` to child loads, meaning presentations and presenters were fetched from API but **never written to IndexedDB**.
2. **Missing microtask yields**: Even when caching was enabled, components would mount and subscribe to liveQuery *before* IndexedDB writes completed, causing race conditions.
2. **Missing microtask yields**: Even when caching was enabled, components would mount and subscribe to liveQuery _before_ IndexedDB writes completed, causing race conditions.
**Example of the Bug:**
```typescript
@@ -89,14 +130,114 @@ $effect(() => {
- When you have chains (presentations depend on session; presenters depend on presentation.person_id), make the dependent liveQuery explicitly wait for the upstream ID and log inside each query to verify the order — adding a small `await Promise.resolve()` or `await 0` inside the `liveQuery` is sometimes useful during debugging to ensure the JS microtask queue has a chance to settle after DB writes.
## Practical Patterns from Aether (Journals & Events)
## IDB Sort: `build_tmp_sort` Pattern (2026-05)
All Aether objects support `priority`, `sort`, `group`, and `name` fields. Rather than sorting in JS after a Dexie query (which requires `.reverse()` hacks and duplicated logic), pre-compute up to three `tmp_sort_*` string fields during the processing pipeline and store them in Dexie. Then `.sortBy('tmp_sort_2')` does the right thing in one call, with no `.reverse()`.
**Utility:** `src/lib/ae_core/core__idb_sort.ts``build_tmp_sort()`
```typescript
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
// Inside specific_processor callback:
const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({
prefix: [obj.group ?? '0'], // always first
priority: obj.priority, // boolean; true→'0' so ASC sorts it first
sort: obj.sort, // zero-padded to 8 chars
fields_1: [...], // module-specific tier-1 fields
fields_2: [...], // tier-2 fields (tmp_sort_2 = base + tier-1 + tier-2)
fields_3: [...] // tier-3 fields
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
obj.tmp_sort_3 = tmp_sort_3;
```
**Sort chain convention:** `group → priority DESC → sort ASC → [module-specific] → name`
**Priority encoding:** `priority ? '0' : '1'` — inverted so that `priority=true` sorts first in ascending order. This means:
- **Dexie `.sortBy('tmp_sort_*')`** — always call without `.reverse()` before it (Dexie ignores collection-level `.reverse()` when using `.sortBy()`). If descending is needed for non-tmp_sort fields, call `.reverse()` on the resulting array after `await`.
- **JS `.sort()` comparators** — use **ascending** `a.localeCompare(b)`, NOT `b.localeCompare(a)`. Using descending flips the priority encoding and puts `priority=false` items first.
```ts
// ✅ Correct — ascending; priority=true ('0') sorts before priority=false ('1')
list.sort((a, b) => (a.tmp_sort_1 ?? '').localeCompare(b.tmp_sort_1 ?? ''));
// ❌ Wrong — descending inverts the encoding; priority=false ('1') sorts first
list.sort((a, b) => (b.tmp_sort_1 ?? '').localeCompare(a.tmp_sort_1 ?? ''));
```
**Modules using `build_tmp_sort`:**
- `ae_events__event_presentation.ts``tmp_sort_1/2`: group → priority → sort → start_datetime → code → name
- `ae_events__event.ts``tmp_sort_1/2/3`: group → priority → sort → name → updated_on (used by IDAA recovery meetings)
- `ae_journals__journal.ts``tmp_sort_1/2/3`: group → priority → sort → name → updated_on
- `ae_journals__journal_entry.ts` — same chain as journal
**Legacy encoding (not yet migrated to `build_tmp_sort`):** `ae_posts__post.ts`, `ae_posts__post_comment.ts`, `ae_archives__archive.ts`, `ae_archives__archive_content.ts`, `ae_sponsorships_functions.ts` use the opposite encoding (`priority ? '1' : '0'`, designed for descending sort). Their current route consumers sort by date/name so there is no visible priority bug today, but they must be migrated before any route starts sorting by `tmp_sort_*`. See `TODO__Agents.md`.
---
## `$derived.by` Dependency Capture for Extra Filter State
When a `liveQuery` has a SCENARIO 2 fallback (broad search with no IDs), it may run before the debounced search fast path populates `event_session_id_li`. If that fallback doesn't apply the same visibility filter as the fast path, hidden items will briefly appear then disappear ("blink").
**Fix:** capture the filter flag as a `$derived.by` dependency in the outer closure so Svelte recreates the liveQuery instance whenever it changes — SCENARIO 2 then uses the correct filter from first render.
```typescript
let lq__event_session_obj_li = $derived.by(() => {
const ids = event_session_id_li; // drives SCENARIO 1 vs 2
const event_id = $events_slct?.event_id;
const qry_hidden = pres_mgmt_loc.current.qry_hidden; // extra dependency
return liveQuery(async () => {
// SCENARIO 1 — specific IDs (fast path or API result)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db.session.bulkGet(ids);
return results.filter(Boolean);
}
// SCENARIO 2 — broad fallback, uses captured qry_hidden
if (event_id && !someFilter) {
const all = await db.session.where('event_id').equals(event_id).sortBy('name');
return all.filter((s: any) => {
if (qry_hidden === 'not_hidden') return !s.hide;
if (qry_hidden === 'hidden') return !!s.hide;
return true; // 'all'
});
}
return [];
});
});
```
**Key rule:** anything read inside `$derived.by()`'s outer closure (but outside the `liveQuery` callback) becomes a Svelte reactive dependency. Changes to it recreate the liveQuery. Use this to synchronize filter flags that Dexie doesn't track.
**Also fix the API call:** use the snapshot value from `params` (captured at debounce time) rather than the live store, so rapid toggling doesn't create a mismatch between fast path and API results:
```typescript
// Bad — uses live store value, can race if user toggles during pending call:
hidden: pres_mgmt_loc.current.qry_hidden ?? 'not_hidden'
// Good — uses snapshot captured when handle_search_refresh was called:
hidden: params.qry_hidden ?? 'not_hidden'
```
---
## Practical Patterns from Aether (Journals & Events & IDAA Recovery Meetings)
- Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page `+page.ts` blocks to populate DB for critical views, or (b) components accept `data.initial_*` fallback values until `liveQuery` emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy.
- Journals broad views: if text search is empty, let the local IDB result set drive the visible list. The API can revalidate the cache in the background, but it should not replace a broad "All" view with a limited slice that hides valid rows.
- Sessions / Presentations: The session page demonstrates several best practices:
- Use `url_*` constants (derived from `data.params`) so the `liveQuery` closure captures a stable value instead of the reactive store directly.
- Provide `initial_session_obj` from `+page.ts` as a first-draw fallback to child components.
- Use `$derived.by(() => liveQuery(...))` for presentation lists so the observable instance is stable across renders and recreated only when `event_session_id` or `search` changes.
- Search pages with persisted filters or saved query text should keep the auto-search trigger in a page-level `$effect`, but the duplicate guard should live inside the actual search executor. That preserves the first page-load search while blocking repeated identical reruns from localStorage-backed rerenders. In practice:
- derive a single `qry_key` from the search inputs
- debounce in the `$effect`
- compare `qry_key` against a `last_executed_key` inside `handle_search_refresh()`
- keep transient loading flags and trigger counters in session state when the value is only used to force a refresh, not as a persisted preference
Example (presentation list pattern):
```typescript
let lq__event_presentation_obj_li = $derived(
@@ -113,6 +254,8 @@ let lq__event_presentation_obj_li = $derived(
- Add a small `console.log` inside each `liveQuery` closure to confirm when it runs and what `id` it sees.
- Verify that `+page.ts` either `await`s critical loads or returns `initial_*` payloads for first-render hydration.
- Confirm that dependent store values (selected IDs) are assigned before components subscribe — use `untrack` to prevent extra reactive cycles.
- If a search page stops auto-loading after a localStorage change, check whether the duplicate guard was placed in the `$effect` instead of the executor. Guarding too early can suppress the initial search; guard at execution time instead.
- If a broad Dexie-backed list shows fewer rows than a narrower filter, look for a limit or revalidation step overwriting the local IDB result set. Broad views should stay unbounded unless the user is actually narrowing by text.
- Ensure your `liveQuery` closures return quickly and do not throw; any exception inside the query can stop updates.
- If a dependent query appears stale, temporarily add `await 0` in the upstream query or an explicit `Promise.resolve()` after the IDB write to force the microtask queue to flush during debugging.
@@ -233,6 +376,79 @@ export function createLiveQueryStore<T>(query: () => T | Promise<T>) {
The `createLiveQueryStore` function creates a readable store that automatically updates whenever the data in the `friends` table changes. The `$friends` variable in the component will always contain the latest data from the database.
## SvelteKit Layout Hierarchy: Security and Execution Order
Understanding _when_ SvelteKit code runs is critical for private-data modules like IDAA.
### Execution order on any navigation
```text
1. +layout.ts / +page.ts ← run FIRST — before any component mounts
also fired by SvelteKit link prefetch (on hover)
2. Parent +layout.svelte mounts → its $effect blocks run
3. Child +layout.svelte mounts → only if parent called {#render children?.()}
4. +page.svelte mounts → only if every parent in the chain rendered children
5. $effect blocks in all of the above run after mount
```
### The auth-gate consequence
A `{:else if authenticated} {@render children?.()}` block in a `+layout.svelte`
controls whether **everything below it** ever mounts. If the gate blocks rendering,
no child layout or page component instantiates — their `$effect` blocks, event
handlers, and liveQuery closures never run.
```svelte
<!-- (idaa)/+layout.svelte -->
{:else if $ae_loc.trusted_access || $idaa_loc.novi_verified}
{@render children?.()} ← children only mount if this branch runs
{:else}
<p>Access Denied</p> ← children never mount; their $effects never run
{/if}
```
**`$effect` blocks inside a child component cannot bypass a parent layout auth gate.**
They are already inside the gate. Adding redundant auth guards to `$effect` blocks
that only run after a parent has already verified access is unnecessary — and misleads
future readers into thinking the parent gate alone is not sufficient.
### Where the actual pre-gate risk lives: `+page.ts` / `+layout.ts`
Universal load functions run _before_ components mount and _before_ layout effects
execute. They also fire during SvelteKit link prefetch — triggered by the user
hovering a link, even if they never navigate. This makes them unsafe for private data:
```text
User hovers an /idaa/ link →
SvelteKit prefetch fires →
+page.ts runs (no layout has mounted yet, no auth gate has run) →
API call / IDB write happens for an unauthenticated user
```
**Rule for private modules (IDAA, Journals):** `+page.ts` and `+layout.ts` files must
not call any data load functions that write to IDB. Move all data loading to `$effect`
blocks in the corresponding `+page.svelte`, gated inside the auth-checked layout render.
The comments in every `+page.ts` under `src/routes/idaa/(idaa)/` explain this pattern.
### The `$effect` auth guards in IDAA `+page.svelte` files
These ARE still useful — but for a different reason than layout bypass:
```ts
// In bb/+page.svelte
$effect(() => {
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
posts_func.load_ae_obj_li__post(...)
});
```
Because `$ae_loc` is a Svelte 4 coarse-grained store, any unrelated write to it
(iframe height, SWR reload) re-triggers this `$effect`. The guard prevents a spurious
API call if `$idaa_loc.novi_verified` has been cleared between re-runs (e.g. TTL
expiry mid-session). It is a reactivity guard, not a layout-bypass guard.
---
## Page Load Strategies (Avoiding the "Waterfall")
When loading data for a primary page view (e.g., viewing a specific Journal, Session, or Person), you must choose a synchronization strategy to ensure the UI renders correctly on the first load.
@@ -282,7 +498,7 @@ If you must use non-blocking loads, you must pass the initial data to the compon
## The `untrack()` Reactive-Tracking Trap
`untrack()` is used inside `$effect` to read reactive values without registering them as tracked dependencies of that effect. This is correct for most "read-once" values (params, IDs) where you don't want the effect re-running on every change. But it has a silent failure mode: if a value you *need* to re-read is consumed inside `untrack()`, the effect becomes a one-shot and never retries when that value changes.
`untrack()` is used inside `$effect` to read reactive values without registering them as tracked dependencies of that effect. This is correct for most "read-once" values (params, IDs) where you don't want the effect re-running on every change. But it has a silent failure mode: if a value you _need_ to re-read is consumed inside `untrack()`, the effect becomes a one-shot and never retries when that value changes.
### Symptom
@@ -346,7 +562,7 @@ Before wrapping a store read in `untrack()`, ask: **"Do I need this effect to re
Svelte 5's `bind:` directive is more restrictive than previous versions. You can only bind to a simple **Identifier** or **MemberExpression**.
**❌ Invalid Pattern (Causes Compile Error):**
Attempting to normalize a value *inside* the binding will fail.
Attempting to normalize a value _inside_ the binding will fail.
```svelte
<!-- Error: Can only bind to an Identifier or MemberExpression -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />

View File

@@ -114,17 +114,19 @@ corresponding `ticket_N_text` on the template provides the HTML rendered on the
| `priority`, `sort`, `group` | int/str | Standard AE sort fields |
| `notes` | str | Internal notes |
### New Field (pending backend addition)
### Duplex / Single-Sided
| Field | Type | Notes |
|---|---|---|
| `duplex` | bool | **Planned** — when `false`, back section is hidden from print (`@media print`) |
| `duplex` | bool | When `false`, back section is hidden from print (`@media print`) |
The `duplex` field controls whether the back-of-badge section renders during printing.
When `false` (single-sided), `badge_back` gets `print:hidden` applied so only the front
prints. The back section still displays on screen for configuration reference.
The first event using this system (Axonius, NYC, mid-April 2026) uses single-sided PVC
cards on a Zebra ZC10L — `duplex` will be `false` for that event's templates.
`duplex` is in `properties_to_save` and `show_badge_back` is derived from it in
`ae_comp__badge_obj_view.svelte`. (Verified 2026-03-18)
Axonius events use `duplex = false` — single-sided printing only.
---
@@ -155,7 +157,7 @@ The print page (`print/+page.svelte`) or the badge view should conditionally add
</svelte:head>
```
This is not yet implemented — tracked as a pending Phase 1 item.
This is implemented — `style_href` loads via `<svelte:head>` in `print/+page.svelte` and is included in `properties_to_save`. (Verified 2026-03-18)
### CSS Scope
@@ -180,7 +182,7 @@ The `layout` field encodes physical badge stock dimensions. Standard codes to us
| --- | --- | --- | --- |
| `badge_4x5_fanfold` | 4" × 5" (101.6 × 127mm) | `badge_layout_epson_4x5_fanfold.css` | Epson ColorWorks C3500 / ExpoBadge fanfold — preferred for general conference use (ISHLT, demos) |
| `badge_3.5x5.5_pvc` | 3.5" × 5.5" (88.9 × 139.7mm) | `badge_layout_zebra_zc10l_pvc.css` | PVC card, Zebra ZC10L — single-sided, set `duplex=0` |
| `badge_4x6_fanfold` | 4" × 6" (101.6 × 152.4mm) | *(none — Tailwind defaults)* | Generic fanfold fallback; dimensions match the hardcoded Tailwind values |
| `badge_4x6_fanfold` | 4" × 6" (101.6 × 152.4mm) | `badge_layout_epson_4x6_fanfold.css` | Single-sided fanfold; Axonius Adapt 2026 (June 2026). Lanyard hole: 5/8in × 1/8in, centered, 1/4in from top. |
| `badge_4x6_fanfold_tickets` | 4" × 6" + tear-offs | *(pending)* | Fanfold with ticket stubs |
Layout CSS files live in `src/lib/ae_events/badges/css/` and are imported by
@@ -192,6 +194,124 @@ wrapper so multiple layouts can coexist in the bundle without conflict.
---
## cfg_json Reference
All keys are optional. Unknown keys are preserved on save (forward-compatible). Managed via the template form's **Advanced** and **Header & Branding** sections, or directly in phpMyAdmin.
### Visibility
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `hide_badge_header` | bool | `false` | Hides the entire header section (image + logo/text fallback). Auto-true when `background_image_path` is set, unless explicitly overridden. |
| `hide_badge_footer` | bool | `false` | Hides the badge type footer stripe. |
| `hide_title` | bool | `false` | Suppresses the professional title field on the badge front. |
| `hide_affiliations` | bool | `false` | Suppresses the affiliations field. |
| `hide_location` | bool | `false` | Suppresses the location field. |
### QR Codes
These keys override the top-level DB fields (`show_qr_front`, `show_qr_back`) when present. Prefer setting them here rather than the top-level fields.
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `show_qr_front` | bool | `false` | Show attendee QR on badge front. |
| `show_qr_back` | bool | `true` | Show attendee QR (+ ID text) on badge back. |
### Text Alignment
Stored under a nested `align` object.
```json
"align": { "name": "left", "title": "left", "affiliations": "left", "location": "center" }
```
| Key | Values | Default |
| --- | --- | --- |
| `align.name` | `left` \| `center` \| `right` \| `justify` | `center` |
| `align.title` | `left` \| `center` \| `right` \| `justify` | `center` |
| `align.affiliations` | `left` \| `center` \| `right` \| `justify` | `center` |
| `align.location` | `left` \| `center` \| `right` \| `justify` | `center` |
QR alignment stored under `qr_alignment`:
| Key | Values | Default |
| --- | --- | --- |
| `qr_alignment.front` | `left` \| `center` \| `right` | `center` |
| `qr_alignment.back` | `left` \| `center` \| `right` \| `justify` | `center` |
### Header Image
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `header_margin_top` | CSS length | `2rem` | Vertical offset of the header image. Negative = shift up. e.g. `"-0.25in"`, `"1rem"`. |
| `header_border_color` | hex color | none | Bottom border drawn below the header div. **Empty = no border.** e.g. `"#FE6111"`. |
| `header_border_width` | CSS length | `2px` | Thickness of the header bottom border. Only applied when `header_border_color` is set. |
| `header_padding_bottom` | CSS length | none | Space between the header image and the bottom border line. e.g. `"1.45in"`. |
### Appearance
| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `body_text_color` | hex color | `#000000` | Inline color applied to all badge body text. |
| `bleed` | CSS length | none | Extends background image past card edges on all sides. Prevents white borders on printers that clip slightly inside the card. e.g. `"0.125in"`, `"3mm"`. |
### Text Zone Heights (`fit_heights`)
Per-layout height overrides for the auto-scaling text zones. Set any subset — unset keys fall back to the layout default. Useful when `background_image_path` is set and the designed zones don't align with code defaults.
```json
"fit_heights": { "grp_name_title": "1.8in", "name": "1.4in" }
```
| Key | Notes |
|---|---|
| `grp_name_title` | Height of the name+title container |
| `grp_name_title_flex` | Flex distribution: `around` \| `between` \| `even` \| `center` \| `start` \| `end` |
| `name` | Height of the name text zone |
| `title` | Height of the title text zone |
| `grp_aff_loc` | Height of the affiliations+location container |
| `grp_aff_loc_flex` | Flex distribution (same values as above) |
| `affiliations` | Height of the affiliations text zone |
| `location` | Height of the location text zone |
### Punch-Out Hole Markers (`punch_holes`)
Enables X overlays at the physical badge clip slot positions. Slots are pre-perforated on the badge stock — the markers print on the badge so attendees know where to push them out.
**Slot dimensions:** 5/8″ wide × 1/8″ tall, 1/4″ from top edge, 3/8″ from left/right edges. Center slot is horizontally centered.
```json
"punch_holes": { "left": true, "right": true, "center": false }
```
| Key | Default | Notes |
| --- | --- | --- |
| `punch_holes.left` | `false` | Left clip slot marker |
| `punch_holes.right` | `false` | Right clip slot marker |
| `punch_holes.center` | `false` | Center clip slot marker (less common) |
---
### Controls Panel (`controls_cfg`)
Controls which fields appear in the print controls panel for non-trusted users, and which fields authenticated users may edit. Trusted + Edit Mode always sees and can edit all fields regardless of this config.
```json
"controls_cfg": {
"shown": ["name", "title", "affiliations"],
"auth_editable": ["title", "affiliations", "location"]
}
```
| Key | Type | Default |
| --- | --- | --- |
| `controls_cfg.shown` | `string[]` | `["name", "title", "affiliations", "location"]` |
| `controls_cfg.auth_editable` | `string[]` | `["title", "affiliations", "location", "allow_tracking", "pronouns"]` |
Valid field keys: `name`, `title`, `affiliations`, `location`, `pronouns`, `allow_tracking`.
---
## Template-Derived Features (component behavior)
### badge_type_list → badge type select
@@ -218,7 +338,6 @@ The `properties_to_save` array in `ae_events__event_badge_template.ts` controls
gets cached locally. Current state — fields **NOT** in properties_to_save that exist
in DB and may be needed:
- `style_href` — needed once external CSS is wired via `<svelte:head>`
- `passcode` — not needed client-side
- `footer_title`, `footer_left`, `footer_right` — not needed (legacy)
- `header_background`, `footer_background` — not needed (legacy)
@@ -359,6 +478,9 @@ Firefox users can use "Save to PDF" directly — it just works.
- [x] Wire `style_href` via `<svelte:head>` in print page — done in `print/+page.svelte`; also in `properties_to_save`. (2026-03-18 verified)
- [x] Add `duplex` to `properties_to_save` — done. (2026-03-18 verified)
- [x] Add `duplex`-driven suppression to `badge_back` section — done in `ae_comp__badge_obj_view.svelte`; `show_badge_back` derived from `duplex` field.
- [ ] Make `layout` field drive actual card dimensions in the badge component — currently the Zebra ZC10L layout CSS (`badge_layout_zebra_zc10l_pvc.css`) sets dimensions correctly via `[data-layout="..."]` scoping, but fanfold layouts still use Tailwind defaults. Needs proper CSS for each layout code.
- [x] `badge_4x6_fanfold` layout CSS created (`badge_layout_epson_4x6_fanfold.css`), imported in badge component, `@page 4in 6in` wired in print page. (2026-05-15)
- [x] Template form expanded — `layout`, `style_href`, `badge_type_list`, `duplex`, and all `cfg_json` keys now editable via the form. (2026-06-04)
- [x] `cfg_json.header_margin_top`, `header_border_color`, `header_border_width`, `header_padding_bottom` added — header image position and bottom border are fully configurable without a code deploy. (2026-06-04)
- [ ] Wire `badge_type_list` from the template into the badge search filter — currently the search form uses a hardcoded list. See `ae_comp__badge_search.svelte` TODO comment.
- [ ] `badge_4x5_fanfold` layout CSS exists but is stale (not used in 2+ years) — review against actual hardware before next use.
- [ ] Remove dead `exhibitor_info` / `presenter_info` / `staff_info` / `vip_info` / `vote_info` `{#if}` blocks from `ae_comp__badge_obj_view.svelte` (if they were carried over from v1)
- [ ] Improve `ae_comp__badge_template_form.svelte` to edit all relevant fields (currently minimal)

View File

@@ -1,843 +1,130 @@
# MODULE: Aether Events — Badges
# Aether Events — Badges
**Module Path:** `src/routes/events/[event_id]/(badges)/badges/`
**API Module:** `src/lib/ae_events/ae_events__event_badge.ts`
**Database:** `db_events.badge` (Dexie IndexedDB table)
**Last Updated:** 2026-02-27 (rev 6)
**Related Docs:** `documentation/PROJECT__AE_Events_Badges_Review_Print.md` (implementation guide)
The Badges module manages event attendee records and their physical badge configurations. It supports multi-source imports, field protection for onsite edits, and multi-tier access control for self-service review.
---
## Overview
## Data Model & Hierarchy
The Badges module manages event attendee badges with support for:
- **External system imports as needed** (CSV/Excel, iMIS, Zoom, Novi, Impexium, Confex, Cvent, and others)
- **Field override protection** to prevent staff/attendee edits from being overwritten by automated syncs
- **Multi-tier access control** for field editing
- **QR code generation** for badge scanning
- **Print tracking** (count, first/last print datetime)
- **Advanced search and filtering**
- **HTML rendering** in display fields for rich text formatting
- **Accessibility features** (text enlargement toggle)
### Core Objects
- **Event Badge** (`event_badge`): The attendee record containing name, title, affiliations, and tracking flags.
- **Badge Template** (`event_badge_template`): The visual and structural configuration for printing (branding, layout, QR placement).
### Relationships
- **Badge → Event:** Many-to-one.
- **Badge → Template:** Many-to-one (via `event_badge_template_id`).
- **Badge → Person:** Optional link to core Aether Person record for unified profiles.
---
## Critical Design Pattern: Override Fields
### Purpose
The `*_override` fields pattern protects data from being overwritten during scheduled cron syncs from external systems. This is essential because:
1. Staff may need to correct imported data
2. Attendees may be allowed to self-update certain fields (e.g., preferred name, pronouns)
3. External systems often have outdated or incorrect data
4. Changes should persist across multiple sync cycles
The `*_override` fields pattern (established in 2018) protects data from being overwritten during scheduled cron syncs from external systems (iMIS, Novi, etc.). This ensures that staff corrections or attendee self-updates persist across multiple sync cycles.
### How It Works
1. **Import:** External systems populate **REGULAR** fields only.
2. **Display Logic:** The UI displays the `*_override` field if it has a value; otherwise, it falls back to the regular field.
3. **HTML Rendering:** Certain display fields (Name, Title, Affiliations, Location) support HTML markup for rich text formatting (bold, italics, line breaks) on the physical badge.
**Import Behavior:**
```
External System → Aether API → Populates REGULAR fields only
(never touches *_override fields)
```
### Standard Override Pairs
**Display Behavior:**
```
UI Display Logic:
1. IF `*_override` field has value → USE IT (highest priority)
2. ELSE IF regular field has value → USE IT (fallback)
3. ELSE → Display placeholder/empty
```
**HTML Rendering (implemented 2026-02-27):**
Certain fields support HTML markup for rich text formatting. When viewing (not editing), these fields use Svelte's `{@html}` directive to render the markup:
- `full_name` / `full_name_override`
- `professional_title` / `professional_title_override`
- `affiliations` / `affiliations_override`
- `location` / `location_override`
This allows for formatting like:
- Bold/italic: `<b>Dr.</b> Jane Smith` or `<i>Chief Medical Officer</i>`
- Line breaks: `Hospital Name<br>Department Name`
- Special characters and entities
**Example — Full Name:**
```typescript
// API imports from iMIS
badge.given_name = "Robert"
badge.family_name = "Smith"
badge.full_name = "Robert Smith" // Auto-computed
// Staff edits to preferred name with HTML
badge.full_name_override = "<b>Bob</b> Smith"
// Display in UI (review form)
{@html badge.full_name_override || badge.full_name || "— no name —"}
// Result: **Bob** Smith (bold rendered)
// Edit mode shows raw HTML
<input bind:value={editable_full_name_override} />
// Shows: <b>Bob</b> Smith (editable as text)
// Next cron sync from iMIS
// ✅ badge.full_name updated to "Robert J. Smith" (middle initial added)
// ✅ badge.full_name_override remains "<b>Bob</b> Smith" (PROTECTED)
// ✅ Display still shows **Bob** Smith (bold rendered)
```
### Override Fields
| Regular Field | Override Field | Purpose | Editable By | HTML Rendering |
|---|---|---|---|---|
| `pronouns` | `pronouns_override` | Preferred pronouns | Staff, Attendee | No |
| `professional_title` | `professional_title_override` | Job title display | Staff, Attendee | ✅ Yes |
| `full_name` | `full_name_override` | Preferred name display | Staff, Attendee | ✅ Yes |
| `affiliations` | `affiliations_override` | Organization display | Staff, Attendee | ✅ Yes |
| `phone` | `phone_override` | Phone number | Staff, Attendee | No |
| `email` | `email_override` | Contact email override | Staff only | No |
| `location` | `location_override` | City/State/Country display | Staff, Attendee | ✅ Yes |
| `badge_type` | `badge_type_override` | Badge category label text | Staff only | No |
| `badge_type_code` | `badge_type_code_override` | Badge access level code | Staff only | No |
| `registration_type` | `registration_type_override` | Registration category label text | Staff only | No |
| `registration_type_code` | `registration_type_code_override` | Registration category code | Staff only | No |
> **Note:** `phone`, `phone_override`, `pronouns_override`, `registration_type`, `registration_type_code`, `registration_type_override`, `registration_type_code_override` may need to be confirmed against the DB schema via `ae_describe event_badge` and added to `properties_to_save` in `ae_events__event_badge.ts` if not already present.
### Sync Safety Rules
**Automated Sync (Cron Jobs):**
- ✅ CAN update: All regular fields (`given_name`, `family_name`, `email`, `affiliations`, etc.)
- ❌ CANNOT update: Any `*_override` field
- ❌ CANNOT delete: Any `*_override` value
**Manual Staff Edit:**
- ✅ CAN update: Any field (including overrides)
- ✅ CAN clear: Override fields (reverts to regular field)
**Attendee Self-Service Edit:**
- ✅ CAN update: Only specific override fields (per event config)
- ✅ CAN clear: Their own override fields
- ❌ CANNOT edit: Regular fields, badge_type, email_override
| Regular Field | Override Field | Editable By | HTML? |
|---|---|---|---|
| `full_name` | `full_name_override` | Staff, Attendee | ✅ |
| `professional_title` | `professional_title_override` | Staff, Attendee | ✅ |
| `affiliations` | `affiliations_override` | Staff, Attendee | ✅ |
| `location` | `location_override` | Staff, Attendee | ✅ |
| `email` | `email_override` | Staff Only | No |
| `badge_type` | `badge_type_override` | Staff Only | No |
---
## External System Integration
### Supported Import Sources
- **iMIS** (Association Management)
- **Zoom** (Virtual event registration)
- **Novi AMS** (Association Management)
- **Impexium** (Association Management)
- **Confex** (Event abstract management)
- **Cvent** (Event registration)
- **Custom CSV/Excel** imports
Aether acts as a **Pull-Only** consumer for registration data. It does not push changes back to external systems, maintaining them as the source of truth for base registration while Aether handles the "Onsite Truth."
### Data Flow Direction
```
External Systems ─────────> Aether
(READ ONLY) (WRITE + DISPLAY)
```
**Important:** Aether is **pull-only** — does not push changes back to external systems. This prevents sync conflicts and maintains external systems as the source of truth for base data.
### Sync Behavior
- **Frequency:** Scheduled cron jobs (typically hourly, daily, or on-demand)
- **Method:** Full sync or incremental (depends on external system API)
- **Conflict Resolution:** Override fields always win
**Pseudocode:**
```python
def sync_badge_from_external(external_badge_data, existing_badge):
# Update regular fields from external source
existing_badge.given_name = external_badge_data.first_name
existing_badge.family_name = external_badge_data.last_name
existing_badge.email = external_badge_data.email
existing_badge.affiliations = external_badge_data.organization
existing_badge.badge_type_code = external_badge_data.registration_type
# NEVER TOUCH OVERRIDE FIELDS
# existing_badge.full_name_override ← PROTECTED
# existing_badge.affiliations_override ← PROTECTED
# existing_badge.email_override ← PROTECTED
return existing_badge
```
### Supported Sources
- **iMIS**, **Novi AMS**, **Impexium** (Associations)
- **Zoom**, **Cvent** (Registrations)
- **Confex** (Abstracts/Presenters)
- **Custom CSV/Excel**
---
## Access Control & Edit Permissions
## Access Control & Permissions
### Access Levels (Ascending)
1. **Anonymous** — No access to badges
2. **Public** View public event info only (no badge access)
3. **Authenticated** — View own badge, limited self-edit
4. **Trusted** — Search all badges, view all, edit own
5. **Administrator** — Full CRUD, bulk operations, override any field
6. **Manager** — All administrator + event configuration
7. **Super** — All manager + cross-event operations
| Level | Access |
|---|---|
| **Authenticated** | View own badge, limited self-edit (overrides only). |
| **Trusted** | Search all badges, view all, reprint existing badges. |
| **Administrator** | Full CRUD, bulk operations, override any field. |
| **Manager** | All Admin + Event/Template configuration. |
### Current Implementation (v3) — 2026-02-27
#### Badge Search Results Visibility
| Access Level | Sees |
| --- | --- |
| Below Trusted (incl. anonymous) | Only badges where `print_count < 1` and not hidden |
| Trusted, not Edit Mode | Only badges where `print_count < 1` and not hidden |
| Trusted + Edit Mode | All badges where `hide === false` (including already-printed) |
#### Print Button Behavior (per result row)
| Access Level | Print Action |
| --- | --- |
| Below Trusted | No print action — name shown with User icon, non-interactive |
| Trusted, `print_count < 1` | Clickable link → `/print` page, Printer icon |
| Trusted, `print_count >= 1`, not Edit Mode | Disabled (already printed safety lock), shows `Nx` count |
| Trusted, `print_count >= 1`, Edit Mode | Clickable reprint — shows `Nx` count badge next to icon |
Print count displayed as `[Printer][2×] Name` when `print_count >= 1`.
#### Review Area Buttons (per result row, up to 3 buttons total)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Email Review Link | All users | Placeholder `alert()` — will trigger email API |
| Review Link (clipboard) | Trusted + Edit Mode only | Copies `/review` URL to clipboard; shows `Copied!` feedback |
| *(direct Review link)* | *(future)* | *(not yet implemented as separate nav button)* |
#### Badge Edit Form (`ae_comp__badge_obj_view.svelte`)
**Currently editable fields (local `edit_mode_active`, not global `edit_mode`):**
```typescript
editable_full_name_override: string | null
editable_professional_title_override: string | null
editable_affiliations_override: string | null // textarea
editable_location_override: string | null
editable_allow_tracking: boolean | null
editable_email: string | null
editable_badge_type_code: string | null
```
- Save button → `handle_save_changes()` — only changed fields sent to API
- Cancel button → `handle_cancel_changes()` — reverts to IDB values
- **IMPORTANT:** This component must NEVER write to `$ae_loc.edit_mode` — it uses its own local `edit_mode_active` flag only. (Bug fixed 2026-02-27)
#### Badge Review Form (`ae_comp__badge_review_form.svelte`)
Form-based review (NOT a badge render). Used by the `/review` page.
**Props:**
- `can_edit_fields: string[]` prop controls which fields are editable per user level
- `['*']` = administrator (all fields)
- `is_staff: boolean` prop shows/hides the staff-only fields
- Fields show "(overridden)" label when an override value differs from the base field
**Features (implemented 2026-02-27):**
- **HTML Rendering**: `full_name_override`, `professional_title_override`, `affiliations_override`, and `location_override` fields render HTML markup using `{@html}` directive when viewing (not when editing)
- **Accessibility**: Text enlargement toggle button switches between text-2xl (normal) and text-4xl (enlarged) for improved readability
- **Help Modal**: Flowbite Modal component with 6 help sections (Reviewing Badge, Editing Info, Accessibility, QR Code, Lead Scanning, Assistance)
- **QR Code Display**: Generates QR code using `core_func.js_generate_qr_code()` with badge ID, supports hover zoom and click-to-expand
- **Print Status**: Shows print count, first print datetime, and last print datetime at top of form
- **Local Edit Mode**: Independent `local_edit_active` state (never writes to `$ae_loc.edit_mode`)
- **Save/Cancel**: Only changed fields sent to API; revert button for override fields
**Editable Fields:**
- Pronouns, Full Name, Professional Title, Affiliations, Phone, Location (all with override support)
- Allow Tracking checkbox (lead scanning permission)
- Staff-only: Email, Badge Type, Registration Type, Hide, Priority, Notes
- Staff-only: Options (`other_1_code` through `other_8_code`) and Tickets (`ticket_1_code` through `ticket_8_code`)
- Agree to Terms & Conditions checkbox (attendee-visible when in can_edit_fields)
#### Badge Review Page — Header Buttons (implemented 2026-02-27)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Back → Search (ArrowLeft) | Staff (`has_staff_access`) only | `<a href="/events/{id}/badges">` |
| Print (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | `<a href="/print">`, shows `Nx` count if reprinting |
| Copy Link (clipboard) | Trusted + Edit Mode only | Copies review URL to clipboard; `Copied!` feedback for 2s |
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
#### Badge Print Page — Header Buttons (implemented 2026-02-27)
| Button | Visible To | Behavior |
| --- | --- | --- |
| Back → Search (ArrowLeft) | Always (when badge loaded) | `<a href="/events/{id}/badges">` |
| Print Now (Printer icon) | Trusted+, not printed OR Trusted+Edit if printed | Calls `window.print()` directly (convenience duplicate); print count tracked by component button |
| Review (Eye icon) | Trusted + Edit Mode only | `<a href="/review">` nav link |
| Email Link (Mail icon) | All if not printed; Trusted+Edit if printed | Placeholder `alert()` — email API pending |
#### Badge Review Page — Display Sections (implemented 2026-02-27)
The review form (`ae_comp__badge_review_form.svelte`) displays:
1. **Print status** ✅ — print count + first/last print timestamps (read-only, hidden if never printed)
2. **QR Code** ✅ — the attendee's badge QR code for scanning at the badge kiosk (for automatic badge search + print flow). Generated using `core_func.js_generate_qr_code()` with `obj_type: 'event_badge'` and badge ID. Supports hover zoom overlay and click-to-expand.
3. **Editable Fields** ✅ — all fields with access-level gating, override support, and HTML rendering for display fields
4. **Options** ✅ (`other_1_code` through `other_8_code`) — Staff: editable text inputs; Attendees: shown as `[✓] Option X` checkmark display only when value exists
5. **Tickets** ✅ (`ticket_1_code` through `ticket_8_code`) — Staff: editable text inputs; Attendees: shown as `[✓] Ticket X` checkmark display only when value exists
6. **Accessibility Toggle** ✅ — Font size enlargement button in sticky header (text-2xl ↔ text-4xl)
7. **Help Modal** ✅ — Attendee guidance modal with 6 sections explaining the review process, editing, QR codes, and lead scanning
#### Default Field Permissions (hardcoded for now — Axonius first show, mid-April 2026)
These are hardcoded in `review/+page.svelte` pending connection to `mod_badges_json.edit_permissions`.
**Attendee (passcode-authenticated / anonymous with link):**
```typescript
[
'pronouns_override',
'full_name_override',
'professional_title_override',
'affiliations_override',
'phone_override',
'location_override',
'allow_tracking', // Exhibitor Leads opt-in
'agree_to_tc', // Terms & Conditions placeholder
]
```
**Trusted Staff and above:**
```typescript
[
'pronouns_override',
'full_name_override',
'professional_title_override',
'affiliations_override',
'email_override',
'phone_override',
'location_override',
'badge_type_code_override', // + badge_type_override (text label)
'registration_type_code_override', // + registration_type_override (text label)
'option_1' ... 'option_8', // i.e. other_1_code ... other_8_code
'ticket_1_code' ... 'ticket_8_code',
'allow_tracking',
'agree_to_tc',
'hide',
'priority',
'notes',
]
```
**Administrator**`can_edit_fields = ['*']` (all fields)
**Badge type options (hardcoded for now):** `member`, `non-member`, `guest`, `exhibitor`, `staff`, `test`
(In future: read from Event Badge Template's configured list)
**Registration type options:** Same list as badge type for now — identical select options.
#### Future: Per-Event Configuration
`event.mod_badges_json.edit_permissions` — placeholder settings UI exists in
`ae_comp__event_settings_badges_form.svelte`. Review page uses hardcoded defaults for now.
The settings form and review page are not yet connected.
```json
{
"authenticated": {
"can_edit": ["pronouns_override", "full_name_override", "professional_title_override", "affiliations_override", "phone_override", "location_override", "allow_tracking", "agree_to_tc"]
},
"trusted": {
"can_edit": ["*attendee_fields", "email_override", "badge_type_code_override", "registration_type_code_override", "option_x", "ticket_x_code", "allow_tracking", "agree_to_tc", "hide", "priority", "notes"]
}
}
```
### Attendee Self-Service (`/review`)
Attendees can access their own record via a passcode-gated link (typically `?passcode=...`). This allows them to verify their info and provide preferred name/title overrides before printing.
---
## Search & Filter Capabilities
### Search Component
**File:** `ae_comp__badge_search.svelte`
- **Fulltext Search:** Matches against a consolidated `default_qry_str` (Name, email, IDs).
- **Multi-Word Logic:** Queries like "Scott Idem" are split and treated as `LIKE %Scott% AND LIKE %Idem%`.
- **QR Scan Search:** Scanning an attendee's QR code (from a confirmation email or old badge) immediately jumps to their record.
- **Advanced Filters (Trusted + Edit Mode):** Badge Type, Printed Status, Affiliations, Sort Order.
### Multi-Word Search Fix (2026-02-26)
Fulltext search now correctly handles multi-word queries by splitting on whitespace and applying AND logic per word:
```typescript
// "scott idem" → LIKE '%scott%' AND LIKE '%idem%'
// Previously: LIKE '%scott idem%' (failed to match)
const words = qry.split(/\s+/).filter(w => w.length > 0);
for (const word of words) {
search_query.and.push({ field: 'default_qry_str', op: 'like', value: `%${word}%` });
}
```
**Committed:** dc0f3066
### Visibility Filter (Trusted + Edit Mode)
### Available Filters
Three-option select controlling which records are shown:
**Fulltext Search** (All Users)
- Searches: `default_qry_str` database field
- Includes: Name, email, external IDs
- Type: `LIKE %query%` (case-insensitive)
- Trigger: Enter key or 3+ characters typed
| Option | Who can set it | Effect |
| --- | --- | --- |
| **Default** | Any | Hides hidden and disabled badges |
| **Show Hidden** | Trusted | Shows hidden badges alongside normal ones |
| **Show Disabled + Hidden** | Manager only | Shows all records regardless of enable/hide flags |
**Advanced Filters** (Trusted Access & Above)
```typescript
// Badge Type Filter
badge_type_code: 'current_member' | 'inactive_member' | 'ex_all' | 'staff' | etc.
// Note: Badge types are defined per Event and Event Badge Template in database table records.
// Common types include: member, nonmember, guest, exhibitor, staff
// This is a work in progress - types vary by event configuration.
### Result Limit Stepper (Edit Mode)
// Print Status Filter
qry_printed_status: 'all' | 'printed' | 'not_printed'
Controls the maximum number of results returned. Only visible in edit mode.
// Affiliations Search
qry_affiliations: string // Separate filter for organization search
| Access Level | Range | Step |
| --- | --- | --- |
| Below Trusted | Fixed 25 | — |
| Trusted | 25 250 | 25 |
| Manager+ | 25 2550 | 25 up to 250, then 100 |
// Sort Options
qry_sort_order:
- 'name_asc' / 'name_desc'
- 'updated_desc' / 'updated_asc'
- 'print_count_desc'
- 'print_first_desc' / 'print_last_desc'
- 'badge_type_asc'
- 'affiliations_asc'
```
### Badge Type Filter — Known Limitation
### QR Scan Search
- Scans badge QR code
- Extracts badge ID
- Auto-fills search with ID
- Jumps to badge detail view
### Search Implementation Pattern
**File:** `badges/+page.svelte` (Lines 117-365)
**Strategy:** Standardized Reactive Search Pattern (Aether UI V3)
1. **Isolate dependencies** into stable `$derived` object
2. **Debounced effect** (300ms) triggers search
3. **Fast Path:** Search IDB first (if not `remote_first`)
4. **Revalidate:** API request updates IDB
5. **LiveQuery:** UI auto-updates from IDB changes
**Search API:** `events_func.search__event_badge()`
```typescript
await search__event_badge({
api_cfg: $ae_api,
event_id: event_id,
fulltext_search_qry_str: qry_str || null,
type_code: type_code || null,
printed_status: printed_status,
affiliations_qry_str: aff_str || null,
order_by_li: order_by_li,
limit: 150,
log_lvl: 0
})
```
---
## Badge Display Logic
### Name Display Priority
```typescript
// Component: ae_comp__badge_obj_li.svelte (Lines 113-121)
if (event_badge_obj?.full_name_override)
display: full_name_override
else if (event_badge_obj?.full_name)
display: full_name
else
display: given_name + ' ' + family_name
```
### Badge View Page
**Route:** `/events/[event_id]/badges/[badge_id]`
**Components:**
- `+page.svelte` — Container with LiveQuery for badge data
- `ae_comp__badge_obj_view.svelte` — Full badge display + edit UI
**LiveQueries:**
```typescript
lq__event_badge_obj = liveQuery(() => db_events.badge.get(event_badge_id))
lq__event_badge_template_obj = liveQuery(() =>
db_events.badge_template.get(badge.event_badge_template_id)
)
```
**Loading States:**
- `is_loading_idb` — Waiting for initial IDB lookup
- If badge not found → "Badge Not Found" error with reload button
- Loader spinner while fetching
---
## Badge Templates
### Purpose
Badge templates define the visual layout and content structure for printed badges:
- Header images/logos
- Field positions and font sizes
- QR code placement
- Ticket/option indicator display
- WiFi credentials display
### Template Selection
Each badge references an `event_badge_template_id`. The template controls:
- Layout (front/back)
- Branding elements
- Which fields to show
- Field formatting rules
### Template Loading
Templates are loaded alongside badges via `inc_template` parameter:
```typescript
load_ae_obj_id__event_badge({
event_badge_id: badge_id,
inc_template: true // Also loads template
})
```
The badge type dropdown in the search form uses a **hardcoded list**, not the template's `badge_type_list`. This means the codes shown in the filter may not match the codes used by the current event's template. This is a known gap — the fix requires passing the template object into the search component. Until resolved, staff can still search by name/email and filter results manually.
---
## Print Tracking
### Print Fields
```typescript
print_count: number // Increments each print
print_first_datetime: string // ISO datetime of first print
print_last_datetime: string // ISO datetime of most recent print
```
Aether tracks the lifecycle of every physical badge to prevent unauthorized reprints and monitor kiosk activity.
### Print Button (Implemented 2026-02-26)
The `handle_print_badge()` function in `ae_comp__badge_obj_view.svelte` increments the count and records timestamps:
```typescript
async function handle_print_badge() {
const now = new Date().toISOString();
const current_print_count = $lq__event_badge_obj.print_count ?? 0;
const data_to_update = {
print_count: current_print_count + 1,
print_last_datetime: now
};
if (current_print_count === 0) {
data_to_update.print_first_datetime = now; // Only set on first print
}
await events_func.update_ae_obj__event_badge({ ... });
}
```
| Field | Purpose |
|---|---|
| `print_count` | Increments on every "Print Badge" action. |
| `print_first_datetime` | Timestamp of the very first print. |
| `print_last_datetime` | Timestamp of the most recent print. |
Button has `data-testid="badge-print-btn"` and shows loading/done/error states with icon feedback.
### Print Workflow
1. **Pre-Print:** Badge print page (`/print`) shows "Already printed N times" warning in screen-only header if `print_count >= 1`
2. **Record:** `handle_print_badge()` updates `print_count`, `print_last_datetime`, and `print_first_datetime` (first print only) via API before printing
3. **Print:** `window.print()` — standard browser print dialog, wired and working (2026-02-27)
4. **Redirect:** After 1 second, `goto(/events/{id}/badges)` returns to search
5. **Audit:** `print_first_datetime` and `print_last_datetime` visible in Edit Mode debug row
**Browser vs Electron:** Badge printing does NOT require the Electron native app. The standard browser print dialog works well across Chrome, Chromium, and Firefox. The Electron native app is specialized for the **Events Pres Mgmt Launcher only** and should not be assumed available for badge stations.
> **Operational Note:** Reprints triggered via the Edit Mode shortcut do not increment the count; only the formal "Print Badge" workflow does.
---
## Database Schema
## Route Map (Badges)
### IndexedDB Table: `badge`
**File:** `src/lib/ae_events/db_events.ts` (Lines 841-852)
**Indexed Fields:**
```typescript
badge: `
event_badge_id, id,
event_id,
full_name, full_name_override, email, email_override,
affiliations, affiliations_override,
badge_type, badge_type_code, badge_type_code_override, badge_type_override,
external_event_id, external_id, external_person_id,
default_qry_str,
alert,
tmp_sort_1, tmp_sort_2,
print_count, print_first_datetime, print_last_datetime,
enable, hide, priority, sort, group, notes, created_on, updated_on
`
```
### Saved Properties
**File:** `ae_events__event_badge.ts` (Lines 495-563)
**Complete field list** (67 fields total):
- Identity: `id`, `event_badge_id`, `event_id`, `event_badge_template_id`
- Name: `pronouns`, `informal_name`, `title_names`, `given_name`, `middle_name`, `family_name`, `designations`
- Professional: `professional_title`, `professional_title_override`
- Display: `full_name`, `full_name_override`
- Organization: `affiliations`, `affiliations_override`
- Contact: `email`, `email_override`
- Address: `address_line_1`, `address_line_2`, `address_line_3`, `city`, `country_subdivision_code`, `state_province`, `state_province_abb`, `postal_code`, `country_alpha_2_code`, `country`, `full_address`
- Location: `location`, `location_override`
- Classification: `badge_type`, `badge_type_code`, `badge_type_override`, `badge_type_code_override`
- External: `external_event_id`, `external_id`, `external_person_id`
- Search: `query_str`, `default_qry_str`
- System: `alert`, `enable`, `hide`, `priority`, `sort`, `group`, `notes`, `created_on`, `updated_on`
- Print: `print_count`, `print_first_datetime`, `print_last_datetime`
- Sorting: `tmp_sort_1`, `tmp_sort_2`
- Person Link: `person_external_id`, `person_external_sys_id`, `person_given_name`, `person_family_name`, `person_full_name`, `person_professional_title`, `person_affiliations`, `person_primary_email`, `person_passcode`
---
## API Functions
### CRUD Operations
**File:** `src/lib/ae_events/ae_events__event_badge.ts`
```typescript
// Load single badge
load_ae_obj_id__event_badge({ event_badge_id, event_id, inc_template })
// Load badge list
load_ae_obj_li__event_badge({ event_id, view, limit, order_by_li })
// Search badges (V3 API)
search__event_badge({
event_id,
fulltext_search_qry_str,
type_code,
printed_status,
affiliations_qry_str,
order_by_li
})
// Create badge
create_ae_obj__event_badge({ event_id, data_kv })
// Update badge
update_ae_obj__event_badge({ event_badge_id, event_id, data_kv })
// Delete badge
delete_ae_obj_id__event_badge({ event_badge_id, event_id, method })
```
### Field Processing
**Function:** `process_ae_obj__event_badge_props()`
**Processing Steps:**
1. Map `*_random` fields to clean names (`event_badge_id_random``event_badge_id`)
2. Set primary `id` field from `event_badge_id`
3. Ensure `event_id` is set (from function parameter if missing)
4. Calculate `tmp_sort_1` and `tmp_sort_2` for efficient sorting
5. Return processed objects
**Critical Fix (2026-02-26):** All CRUD functions now return **processed** data (matches IDB cache) instead of raw API responses. This ensures consistency between function return values and cached data.
---
## Component Architecture
### Route Structure
```
/events/[event_id]/(badges)/badges/
├── +layout.svelte # Layout wrapper (minimal)
├── +page.svelte # Badge list + search
├── ae_comp__badge_search.svelte # Search form + filters
├── ae_comp__badge_obj_li.svelte # Badge list display (results)
├── ae_comp__badge_create_form.svelte # (Not actively used)
├── ae_comp__badge_upload_form.svelte # Bulk CSV upload
└── [badge_id]/
├── ae_comp__badge_obj_view.svelte # Badge rendering + staff edit + print button
├── ae_comp__badge_review_form.svelte # Form-based field review/edit (attendee + staff)
├── print/
│ ├── +page.ts # Non-blocking badge loader (inc_template: true)
│ └── +page.svelte # Print-focused page — screen header + badge render
└── review/
├── +page.ts # Non-blocking badge loader (inc_template: false)
└── +page.svelte # Passcode-gated review page
```
> **Note:** The old `[badge_id]/+page.svelte` placeholder was removed (2026-02-27). The name link in the search results list now goes directly to `/print`.
#### Badge Print Page (`/print`)
- Screen-only header (`print:hidden`): "Back to Search" link + "Already printed N times" warning
- Badge rendered via `ae_comp__badge_obj_view` with `is_review_mode={false}`
- Print button inside `ae_comp__badge_obj_view` handles count update → `window.print()` → redirect to search
- Page `<title>` includes badge name + event name
#### Badge Review Page (`/review`)
- Passcode-gated for attendees — URL `?passcode=...` matched against `badge.person_passcode`
- **Note:** `person_passcode` field is not yet in the DB (as of 2026-02-27). Review page accessible to staff via `trusted_access` without a passcode.
- Access hierarchy (checked in order):
1. Administrator → full access (`can_edit_fields = ['*']`)
2. Trusted Staff → staff field set
3. Attendee with valid passcode → attendee field set
4. No access → passcode entry form shown
- Uses `ae_comp__badge_review_form.svelte` (NOT badge render)
- "Back to Search" link shown for staff only
### Key Components
**Badge List Page** (`+page.svelte`)
- **LiveQuery:** Reactive badge list from IDB
- **Search Pattern:** Debounced search with fast path + revalidation
- **ID List:** `event_badge_id_li` drives LiveQuery
- **Loading State:** Shows spinner when `search_status === 'loading'`
**Badge Search** (`ae_comp__badge_search.svelte`)
- **Form Mode:** Toggle between search form and QR scanner
- **Filters:** Badge type, print status, affiliations, sort order (trusted+ only)
- **Fulltext:** Name/email search (all users)
- **QR Scan:** Integrated QR scanner for badge ID lookup
**Badge List Display** (`ae_comp__badge_obj_li.svelte`)
- **Visibility Filter:** Respects `hide` flag (trusted+ sees all)
- **Display Logic:** Override → regular → fallback pattern
- **Print Indicator:** Green checkmark badge shows `print_count`
- **Metadata:** ID, created/updated timestamps (edit mode only)
**Badge Detail View** (`ae_comp__badge_obj_view.svelte`)
- **Edit Mode:** Activated by edit button (or `#review` URL hash for future self-service)
- **Condition:** Renders only when BOTH `$lq__event_badge_obj` AND `$lq__event_badge_template_obj` are non-null
- **Form Binding:** Direct `bind:value` on editable fields
- **Dynamic Sizing:** Font size adjusts based on text length
- **Print Preview:** Full badge layout with template
- **Save Handler:** Only sends changed fields to API
- **`data-testid` attributes:** `badge-edit-btn`, `badge-save-btn`, `badge-cancel-btn`, `badge-print-btn`, `badge-professional-title-input` — use these in tests
---
## Testing Status
### Current Test Coverage
- ✅ Badge list loads (all 6 data integrity tests passing)
- ✅ Badge template list loads and displays
- ✅ Badge template form renders and populates correctly
- ✅ Badge template values persist in edit form
- ✅ Electron bridge compatibility (graceful degradation in browser)
- ✅ Badge field processor handles missing optional fields
- ✅ Badge type filter tests
- ✅ Badge template relationship tests
-**Attendee workflow test** — navigate → edit professional title → print → return (d1ded2d4)
### Key Test Lessons Learned
**Search API path is FLAT, not nested.** `search_ae_obj` builds `/v3/crud/{obj_type}/search` — always flat regardless of the parent relationship. Mocks must match this:
```typescript
// CORRECT — flat path
url.includes('/v3/crud/event_badge/search') && method === 'POST'
// WRONG — nested path, mock will never fire
url.includes(`/v3/crud/event/${event_id}/event_badge/search`) && method === 'POST'
```
**List API (GET) is also FLAT with query params.** `get_ae_obj_li` builds `/v3/crud/{obj_type}/?for_obj_id=...` — always flat. Mocks must check `url.includes('/v3/crud/event_badge_template/') && url.includes('for_obj_id')`.
**CSS `input[value*=...]` selectors don't work with Svelte bind:value.** The CSS selector checks the HTML *attribute*; Svelte's `bind:value` sets the DOM *property* only. In Playwright tests, use `page.getByLabel()` or `locator.inputValue()` instead.
**Dexie requires `_random` ID fields.** Badge objects saved to IDB must include:
```typescript
event_badge_id_random: string // Must be present or Dexie skips the object
id_random: string // Also checked
// Error: "Object is missing a valid ID for table 'badge'"
```
All API mock responses in tests need these fields.
**Badge view requires both badge AND template.** `ae_comp__badge_obj_view.svelte` wraps everything in `{#if $lq__event_badge_obj && $lq__event_badge_template_obj}` — if the template isn't loaded, edit/print buttons and the badge itself don't render. Tests must mock the badge template endpoint.
**Badge GET endpoint (single object):** `/v3/crud/event_badge/{id}` (NOT nested under event). Matches `api.get_ae_obj()` which uses the flat path.
**Badge PATCH endpoint (update):** `/v3/crud/event/${event_id}/event_badge/${badge_id}` (nested under event). Matches `api.patch_ae_obj()` which uses the nested path.
**Use `data-testid` for test selectors.** Key buttons have targets: `badge-edit-btn`, `badge-save-btn`, `badge-cancel-btn`, `badge-print-btn`, `badge-professional-title-input`.
### Remaining Test Issues
None — all current badge tests passing as of 2026-02-26 (f5e98b8c).
---
## Known Issues & Future Enhancements
### Known Issues
1. **Session Cold-Start:** Potential race condition on first load (same as pres mgmt module)
2. **Type Definitions:** Some pre-existing TypeScript errors on external package types (not introduced by badge work)
3. **`person_passcode` not in DB:** Attendee-gated review URL (`?passcode=...`) cannot function until this field is added to the `event_badge` schema. The review page falls back to passcode entry form for non-staff.
4. **Print page CSS:** Badge print rendering and `@page` print styles not yet fine-tuned — expected to need work
5. **`mod_badges_json.edit_permissions` not connected:** Settings UI exists but review page uses hardcoded field defaults
### Implemented (2026-02-27)
-`window.print()` wired to print button (records count first, then prints, then redirects)
- ✅ Dedicated `/print` page — replaces old `[badge_id]/+page.svelte` placeholder
- ✅ Dedicated `/review` page — passcode-gated, access-tiered
-`ae_comp__badge_review_form.svelte` — stub created, full form fields pending
- ✅ Badge search results visibility rules (unprinted-only for non-edit, all for trusted+edit)
- ✅ Badge list: 4 action buttons per row (Print, Review nav, Copy Link, Email Link) — all Lucide icons
- ✅ Print page: 3 action buttons in header (Print Now, Review nav, Email Link) — all Lucide icons
- ✅ Review page: 3 action buttons in header (Print nav, Copy Link, Email Link) — all Lucide icons
- ✅ Print button: not shown when already printed (unless Edit Mode)
- ✅ Print count shown as `Nx` badge next to printer icon
- ✅ Email obscuring for non-trusted users
- ✅ Email Review Link button (placeholder alert — email API pending)
- ✅ Direct Review Link clipboard copy (trusted + Edit Mode only)
- ✅ Fixed: components no longer write to `$ae_loc.edit_mode`
- ✅ Settings UI for `edit_permissions` per event (`ae_comp__event_settings_badges_form.svelte`)
- ✅ All badge module icons converted to Lucide (Font Awesome removed from badge routes)
### Recently Completed (2026-02-27)
-**Badge Review Form**`ae_comp__badge_review_form.svelte` fully implemented (fields, QR, save/cancel, options/tickets, accessibility, help modal)
-**Print font size controls (v1)** — Screen-only `[]/[+]/[↺]` panel on print page; 4 px props added to `ae_comp__badge_obj_view.svelte`; auto-sizing unchanged when props absent
-**Bug fix**`default_authenticated_fields` / `default_trusted_fields` in `review/+page.svelte` corrected (wrong field names caused silent save drops)
### Still Needed — HIGH PRIORITY (first show: April 2026)
### Still Needed — MEDIUM PRIORITY
1. **Email API for review links:** `send_review_email()` is a placeholder `alert()`. Needs actual email send endpoint.
2. **`person_passcode` DB field:** Add to `event_badge` schema to enable attendee-gated review URLs.
3. **Connect `edit_permissions` config:** Read `mod_badges_json.edit_permissions` in review page instead of hardcoded defaults.
4. **Print page CSS / `@page` styles:** Badge rendering, sizing, and print-specific stylesheet.
### Still Needed — FUTURE / LOW PRIORITY
1. **Batch Operations:** Bulk update, bulk print, bulk export
2. **Audit Log:** Track who edited which fields and when
3. **Photo Badges:** Support badge photo upload and display
4. **Real-Time Sync:** WebSocket updates for multi-device badge printing stations
---
## Development Guidelines
### Adding New Override Fields
1. Add `{field}_override` to database schema
2. Add to `properties_to_save` array in `ae_events__event_badge.ts`
3. Update display logic to check override first
4. Add to editable fields in `ae_comp__badge_obj_view.svelte`
5. Update access control config
6. Document in this file
### Testing Override Fields
```typescript
// Simulate external sync
badge.given_name = "External Value"
// User edits
badge.given_name_override = "User Value"
// Next sync (should NOT change override)
badge.given_name = "Updated External Value"
// Display should still show "User Value"
assert(display === badge.given_name_override)
```
### Debugging Search Issues
```typescript
// Enable search logging
log_lvl: 2
// Check search params object
console.log('Search params:', search_params)
// Verify API request
console.log('API request:', { event_id, fulltext_search_qry_str, type_code })
// Check returned IDs
console.log('Badge IDs:', event_badge_id_li)
// Verify IDB contents
db_events.badge.toArray().then(console.log)
```
| URL | Purpose |
|---|---|
| `/events/[id]/badges` | Main search and attendee list. |
| `/events/[id]/badges/templates` | Badge template management. |
| `/events/[id]/badges/[id]/print` | The actual print-ready render page. |
| `/events/[id]/badges/[id]/review` | Attendee-facing self-service form. |
---
## Related Documentation
- [AE API V3 for Frontend](./GUIDE__AE_API_V3_for_Frontend.md)
- [Development Guide](./GUIDE__Development.md)
- [Events Launcher Native Integration](./PROJECT__AE_Events_Launcher_Native_integration.md)
- [Naming Conventions](./AE__Naming_Conventions.md)
---
**Document Status:** 🔄 In Progress
**Last Verified:** 2026-02-27 (rev 5 — field permissions spec added, header buttons implemented, review form fields pending)
**Verified Against:** Code as of 2026-02-27 (branch ae_app_3x_llm)
👉 **[MODULE__AE_Events_Badge_Templates.md](./MODULE__AE_Events_Badge_Templates.md)** (Technical reference for layouts)
👉 **[GUIDE__AE_Events_Badges_Onsite.md](./GUIDE__AE_Events_Badges_Onsite.md)** (Hardware & station setup)
👉 **[GUIDE__AE_Events_Onsite_Runbook.md](./GUIDE__AE_Events_Onsite_Runbook.md)** (Onsite operational checklists)

View File

@@ -0,0 +1,79 @@
# Aether Events — Launcher (Podium Display)
The Launcher module provides the podium display interface that runs on each session room's kiosk machine. It is designed to work in standard browsers but is optimized for the **Aether Desktop (Electron)** native shell.
---
## Operational Modes
| Mode | Use Case | File Handling |
|---|---|---|
| **Default** | Browser on any machine | Files downloaded on demand via browser. |
| **Onsite** | Browser on event network | Faster polling; browser-managed files. |
| **Native** | Electron app on podium Mac | Background pre-cache; atomic file handover. |
For production onsite use, **Native mode on Mac laptops** is the target. The Electron
app pre-caches all session files in the background so presentations open instantly without
a network round-trip at the moment of launch.
---
## Launcher Display Views
| View | Shown When |
|---|---|
| **Session view** | Active session with session-level files. |
| **Presentation view** | Active session with named presentations. |
| **Presenter view** | Presentation selected; shows presenter bio/photo. |
| **Poster/group view** | Special layout for poster sessions. |
| **Screensaver** | No active session; idle state. |
---
## Sync Engine & File Handling
### Background Sync (File Warming)
When a user navigates to a session in the Launcher UI, the background engine automatically warms the cache for that specific session by downloading all associated files.
### Force Sync Location
To ensure full room readiness (e.g., during SRR setup or overnight), operators can trigger a **Force Sync Location** via the configuration menu. This performs a recursive fetch of all sessions, presentations, and presenters for the room and queues every file for the day for download.
### Download Priority & Room Readiness
To ensure the podium is ready for the day's first sessions, the Launcher sync engine uses a 4-tier chronological sorting priority:
1. **Global Assets:** Event and Location level files (branding, walk-in slides) are cached first.
2. **Session Schedule:** Files for the earliest sessions in the room are prioritized.
3. **Presentation Order:** Within a session block, speakers are prioritized by their scheduled start time.
4. **First-In Fairness:** When times are equal, older uploads are prioritized over late revisions (respecting on-time presenters).
### Native File Opening (Safe Handover)
1. Verify SHA-256 hash in permanent cache.
2. Atomic copy to system `[tmp]` directory.
3. Rename to original filename (e.g., `Abstract_101.pptx`).
4. OS opens the file via a **Launch Profile** (AppleScript or Shell command).
---
## Device & Native Integration
Each Launcher kiosk is registered as an `event_device` record in Aether. The technical specifications for the Electron bridge, hashed cache protocol, and hardware actuators are documented in:
👉 **[MODULE__AE_Events_Launcher_Native.md](./MODULE__AE_Events_Launcher_Native.md)**
---
## Route Map (Display)
| URL | Purpose |
|---|---|
| `/events/[id]/launcher` | Launcher home — select location |
| `/events/[id]/launcher/[location_id]` | Launcher display for a specific room |
---
## Access Levels
| Feature | Minimum Access |
|---|---|
| View Launcher display | `authenticated_access` |
| Manual session selection | `trusted_access` |
| Advanced Config / Sync Control | `trusted_access` (via Configuration Drawer) |

View File

@@ -0,0 +1,94 @@
# Aether Events — Launcher Configuration Menu (Inventory)
> **Status:** Current Reference (v3.0)
> **Location:** `src/routes/events/[event_id]/(launcher)/launcher_cfg.svelte`
This document provides a detailed inventory of the Launcher's configuration menu settings as of May 2026. This serves as the baseline for the v3.1 reorganization into a modal-based tabbed interface.
---
## 1. UI Architecture & Visibility
The configuration menu currently resides in a slide-out **Drawer** (sidebar).
### 1.1 Visibility Modes
- **Standard Mode:** Default view for onsite operators. Hides advanced technical and destructive controls.
- **Technical Mode (`$ae_loc.edit_mode`):** Toggled via a subtle pencil icon. Reveals advanced diagnostic fields, manual overrides, and debug tools.
- **Native Mode (`$ae_loc.is_native`):** Automatically detected when running in the Electron shell. Shows OS-level controls (Filesystem, Power, Apps).
### 1.2 Section Expansion Logic
- **`collapsed`**: Content hidden.
- **`auto`**: Expanded by default; collapses when another "auto" section opens.
- **`pinned`**: Remains expanded regardless of other interactions.
---
## 2. Menu Inventory (Tabbed View)
### Tab 1: Setup (Onsite Operator Focus)
| Section | Feature | Technical Mode Only |
| :--- | :--- | :--- |
| **Display & App Modes** | Session Mode Preset (Oral vs Poster Kiosk) | |
| | Operational Env (Web / App / Onsite) | |
| | Interface Visibility (Hide Header/Menu/Footer/Times) | |
| | Clock Format (12/24 hour) | |
| | WebSocket Debugger Toggle | Yes |
| | Poster Modal Title Toggle | Yes |
| | Native Test Mode (Simulation) | Yes |
| **Remote Controller** | WS Connection Status Badge | |
| | Controller Strategy (Local / Remote / Local Push) | |
| | Connect / Disconnect Action | |
| | Group Reload (WS trigger) | |
| | Channel Group Code (Locked/Unlockable) | Yes |
| **Poster Screen Saver** | Idle Timeout Summary | |
| | Timer Overrides (Idle / Cycle / Loop) | Yes |
### Tab 2: Device (Technical & Native Focus)
| Section | Feature | Technical Mode Only |
| :--- | :--- | :--- |
| **Sync Engine & Timers** | Pause / Resume Sync | |
| | Force Sync Location (Recursive fetch) | |
| | Polling Periods (Event/Device/Loc/Sess/Pres/Presenter) | Yes |
| | Cache Hash Prefix Length (1-3 chars) | Yes |
| **System & Sync Health** | CPU & RAM Usage Gauges | |
| | Heartbeat Status & Timestamp | |
| | Sync Progress (Cached vs Total) | |
| | Active Sync Filename (Animated) | |
| | Hostname & IP List | Yes |
| | Raw Device JSON Inspector | Yes |
| **Native OS Management** | Open Cache / Temp Folders | |
| | Window Control (Maximize / Kiosk) | |
| | Display Mode (Extend / Mirror) | |
| | Presentation Remote (Prev/Start/Stop/Next) | |
| | Reset Wallpaper (Site Header) | Yes |
| | Kill Presentation Apps (PowerPoint/Keynote/etc) | Yes |
| | Power Actions (Reboot / Shutdown) | Yes |
| | Manual Terminal Command Entry | Yes |
| **Wallpaper** | Primary Display URL Preset/Input | |
| | External Display URL Preset/Input | |
| | Save & Apply Wallpaper | |
| | Restore macOS Default | |
| **Launch Timing** | Per-Profile Post-Open Delay (ms) Overrides | Yes |
| **Application Updates** | Update Source (File / URL) | Yes |
| | Check for Updates | |
| | Install & Relaunch | |
### Tab 3: Dev (Technical/Developer Focus)
| Section | Feature | Technical Mode Only |
| :--- | :--- | :--- |
| **Local Reset & Actions** | Maintenance Select (Wipe IDB / LocalStorage) | Yes |
| | Global Sys Menu Toggle | Yes |
| | Global Debug Menu Toggle | Yes |
| | Cache .tmp Cleanup (Native Only) | Yes |
| | API Endpoint & Account ID Summary | Yes |
---
## 3. Global Actions (Footer)
- **Close:** Dismisses the configuration menu.
- **Reload:** Performs a full browser `location.reload()`.
- **Debug Panel:** Opens the raw state inspector (Technical Mode Only).

View File

@@ -0,0 +1,110 @@
# Aether Events — Unified Launcher Configuration (Vision v3.1)
> **Status:** Strategic Design / Unified Proposal
> **Author:** Gemini CLI (Interactive Agent)
> **Target:** Full consistency across all configuration modules.
## 1. Unified Design Language
To eliminate the "created by 3 different people" feel, all components must strictly adhere to this shared specification.
### 1.1 Color Palette & Semantics
- **Primary (Blue):** Main actions, active tabs, and standard configuration toggles.
- **Secondary (Green):** Safe actions (Connect, Sync, Apply).
- **Warning (Orange):** Technical overrides that require caution (Timers, Native Shell).
- **Error (Red):** Destructive actions (Resets, Shutdown, Kill Apps).
- **Surface (Gray):** Containers, input backgrounds, and inactive states.
### 1.2 Typography & Spacing
- **Section Headers:** `text-sm font-bold uppercase tracking-tight` (Provided by Wrapper).
- **Field Labels:** `text-[10px] font-bold uppercase tracking-wider opacity-60 mb-1`.
- **Sub-Descriptions:** `text-[9px] italic opacity-40 leading-snug mt-1`.
- **Status Badges:** `text-[8px] font-bold uppercase tracking-tighter`.
- **Grid Standard:**
* Single Column for complex fields.
* `grid-cols-2` with `gap-4` for standard inputs.
* `grid-cols-3` or `grid-cols-4` only for small buttons or icon toggles.
---
## 2. Structural Reorganization (The "Aether" Layout)
The menu is now a **Vertical Sidebar Modal**. This allows for persistent navigation while dedicating the large right pane to content.
### Tab 1: 🖥️ Display (General Operator)
*Focus: What the screen looks like.*
- **Category: Layout & UI**
- Presets: Oral/Default vs Poster Kiosk (One-tap setup).
- Toggles: Header, Menu, Footer, Times visibility.
- Formatting: Clock (12/24h), Date formats.
- **Category: Screen Saver**
- Idle Timeout (Minutes).
- Mode: Image Cycle vs Video vs Custom.
### Tab 2: 🔌 Connectivity (Onsite Tech)
*Focus: How it talks to the network.*
- **Category: WebSocket Control**
- Connection Status & Signal Strength.
- Controller Mode: Local vs Remote vs Push.
- Group Code: Channel sharding for multi-room management.
- **Category: API Context**
- Current Endpoint, Account, and Site context.
### Tab 3: 🔄 Sync & Health (Onsite Tech)
*Focus: Data integrity and performance.*
- **Category: Sync Engine**
- Status: Active vs Paused.
- Action: Force Sync Location (recursive metadata fetch).
- Stats: Cached Files vs Total Files (Progress bar).
- **Category: System Telemetry**
- CPU & RAM usage (Visual gauges).
- Heartbeat monitor (Last success timestamp).
- Device Identity: Hostname, IP list, Local paths.
### Tab 4: 🛠️ Native Shell (Specialized / Mac)
*Focus: OS-level capabilities.*
- **Category: App Control**
- Window: Maximize, Kiosk Mode, Fullscreen.
- Automation: Kill presentation apps (Clean slate).
- Remote: Virtual clicker (Prev/Next/Start/Stop).
- **Category: System Action**
- Displays: Extend vs Mirror (Native bridge).
- Folders: Open Cache / Open Temp.
- Power: Reboot / Shutdown (With confirmation).
### Tab 5: 🖼️ Wallpaper (Branding)
*Focus: Event-specific aesthetics.*
- **Category: Customization**
- Primary Display: URL/Preset.
- Secondary/Projector: URL/Preset.
- Action: Apply to OS (Native) + Preview (Web).
### Tab 6: 🧪 Advanced (Developer Mode)
*Focus: Fine-tuning and updates.*
- **Category: Performance**
- Polling Intervals (Event, Device, Room, Session, Presenter).
- Cache Sharding (Prefix length).
- **Category: Launch Logic**
- Per-Profile Post-Open Delays (ms).
- **Category: Updates**
- Source: File vs URL.
- Version: Current vs Target.
- Action: Download/Install.
### Tab 7: 🧹 Maintenance (Emergency)
*Focus: Troubleshooting.*
- **Category: Resets**
- Wipe IndexedDB (Module selective).
- Clear LocalStorage (Reset config).
- **Category: Diagnostics**
- Raw Device JSON inspector.
- Terminal Command Entry.
---
## 3. Implementation Plan: The "Cohesion" Refactor
1. **Standardize `Launcher_Cfg_Section.svelte`:** Ensure padding and spacing are baked into the wrapper so children don't have to define it.
2. **Create `Launcher_Cfg_Field.svelte`:** A new helper component to handle the Label + Description + Input pattern consistently.
3. **Audit Sub-Components:** Update all 10 components to use the new colors, grid patterns, and typography.
4. **Polish Transitions:** Ensure the Modal entry and Tab switching are butter-smooth with Svelte 5 transitions.

View File

@@ -0,0 +1,275 @@
# Aether Events — Launcher: Native Integration
> **Status:** Operational / Permanent Reference
> **Last Updated:** 2026-05-21 (Reorganized)
> **Primary Platform:** macOS (Darwin)
> **Fallback Platform:** Linux / Windows
## 1. Overview
The Aether Events Launcher utilizes an Electron-based "Native Shell" to provide OS-level capabilities that are normally restricted by browser sandboxing. This enables persistent file caching, direct control of presentation software (Keynote, PowerPoint), and hardware telemetry.
### Operational Modes
| Mode | Purpose | File Handling |
| :--- | :--- | :--- |
| **Default** | Standard web browser access. | Direct downloads; no local caching. |
| **Onsite** | Web access on event networks. | Faster polling; browser-based file management. |
| **Native** | Dedicated Podium Kiosk (Electron). | Full background pre-caching; atomic safe-handover. |
---
## 2. Architecture: The Three-Layer Bridge
The integration is built on a decoupled three-layer communication model to ensure security and cross-platform flexibility.
### 2.1 Layer 1: The Engine (Main Process)
- **Repo:** `~/OSIT_dev/aether_app_native_electron/` (separate git repo)
- **File:** `aether_app_native_electron/src/main/*.ts`
- **Role:** Performs the heavy lifting (Filesystem, Shell, AppleScript).
- **Responsibilities:**
- Managing the **Hashed Cache** directory.
- Executing `osascript` intents for presentation control.
- Spawn/Kill process management.
### 2.2 Layer 2: The Gatekeeper (Preload Script)
- **Namespace:** `window.aetherNative`
- **Role:** Securely exposes whitelisted IPC channels to the Renderer.
- **Standards:** Uses `contextBridge.exposeInMainWorld` to prevent arbitrary code execution.
### 2.3 Layer 3: The Messenger (SvelteKit Relay)
- **File:** `src/lib/electron/electron_relay.ts`
- **Role:** Provides a clean, typed API for Svelte components.
- **Responsibilities:**
- Mapping `camelCase` UI triggers to `snake_case` IPC calls.
- Resolving an extension alias to a canonical Launch Profile, then to a single
`native_template` string before crossing IPC.
The reason for this split is simple: Launch Profiles are policy, while Native Templates are
executable strings. Keeping that distinction explicit prevents the bridge from mixing config
objects with runtime commands.
---
## 3. The "Zero-Config" Lifecycle
To support rapid onsite deployment, the native app requires zero manual setup.
1. **Seed:** On launch, the Main process reads a local `seed.json` (Device ID + API Key).
2. **Identity:** Calls `GET /v3/crud/event_device/{id}` to pull device config and extract `app_base_url` (the event FQDN) and `account_id`.
3. **Site Context:** POSTs to `/v3/crud/site_domain/search?limit=1` with the FQDN to resolve the correct site. No JWT — auth is `x-aether-api-key` + `x-account-id` throughout.
4. **Launch:** Navigates the SvelteKit frontend directly to the assigned Event Launcher route (`/events/{eventId}/launcher/{locationId}`).
---
## 4. Podium Reliability Protocol
The system is designed to ensure that a presentation never fails due to network instability.
### 4.1 Hashed Cache Pattern
Files are stored persistently using their SHA-256 hash to prevent filename collisions and handle versioning.
- **Root:** `~/Library/Caches/OSIT/file_cache/`
- **Subdirectory:** First 2 characters of hash (e.g., `ab/`)
- **Filename:** `{hash}.file`
### 4.2 Background Sync (File Warming)
When a user navigates to a session in the Launcher UI, the `LauncherBackgroundSync` component warms the cache for that specific session. To ensure full room readiness, a **Force Sync Location** trigger is available in the configuration UI.
1. **Metadata Fetch:** The system fetches all sessions, presentations, and presenters for the current location into the local database (Dexie).
2. **Chronological Priority:** Missing files are added to the download queue and sorted to prioritize the event schedule:
- **Tier 1: Global Assets** — Event and Location level files (virtual time 0).
- **Tier 2: Session Schedule** — Earliest sessions are prioritized first.
- **Tier 3: Presentation Order** — Within a session, speakers are prioritized by their start time.
- **Tier 4: Integrity & Fairness** — Tie-breakers use `created_on` (oldest first) to ensure on-time uploads are cached before last-minute revisions.
3. **Download:** Triggers background downloads via `aetherNative.download_to_cache` sequentially to preserve network bandwidth and ensure file integrity.
### 4.3 Safe Handover (Launch Sequence)
When a user clicks "Open", the system follows a non-destructive sequence:
1. **Verify:** Confirm hash exists in the permanent cache.
2. **Copy:** Create an atomic copy in the system `[tmp]` directory.
3. **Restore:** Rename the copy to its original filename (e.g., `Abstract_101.pptx`).
4. **Execute:** Launch the file via the OS.
---
## 5. Automation & Actuators (Phase 5)
The native shell provides specialized handlers for controlling the "Podium Experience."
### 5.1 Presentation Acts
| Action | Handler | Actuator (macOS) |
| :--- | :--- | :--- |
| **Launch** | `launch_presentation` | `open` or `osascript` (slideshow start) |
| **Control** | `control_presentation` | `osascript` (next/prev slide) |
| **Clean Up** | `kill_processes` | `killall -INT` (graceful exit) |
### 5.2 System Management
- **Telemetry:** Pushes `cpu_usage`, `memory_free_gb`, and `foreground_app` via heartbeats using the `get_device_info` relay.
- **Self-Update (Roadmap):** Plan to monitor Syncthing `admin_share` for newer `.app` versions and perform atomic swaps.
### 5.3 Implemented Actuators (Phase 5 Complete)
- **Recording:** `manage_recording({action})` — Aperture session capture (`start`, `stop`, `status`). macOS only.
- **Display Layouts:** `set_display_layout({mode, configStr?})` — Mirror / Extend displays. macOS only. **Primary:** native `display_control` binary (`resources/bin/display_control`) uses CoreGraphics APIs directly — no Homebrew dependency. Built from `scripts/display_control.m` via `scripts/build-display-control.sh` on a Mac; commit the binary to the repo. **Fallback:** [`displayplacer`](https://github.com/jakehilborn/displayplacer) (`brew install displayplacer`) used when binary is absent or `configStr` override is set. Failures are logged to the Electron console but do not block file open. A **Display Mode** toggle (Extend / Mirror) is available in the Launcher config — Native OS section, visible without Technical Mode.
- **Power Control:** `power_control({action})` — Shutdown, reboot, sleep. macOS + Linux.
- **Window Control:** `window_control({action})` — Maximize, minimize, fullscreen, kiosk mode.
- **Wallpaper:** `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Downloads from URL (cached locally) or applies a local path. Per-display targeting (`'all'`/`'primary'`/`'external'`). macOS only in production; Linux returns a dev-mode preview payload.
> **Note:** `update_app` is implemented as a stub — downloads but does not install. Not yet functional for end users.
---
## 6. Launcher Configuration & Management
The Launcher features a standardized, responsive configuration interface designed for onsite technical management.
### 6.1 UI Architecture
- **Tabbed Navigation:** Categorized into System, Sync, and General settings.
- **Section Wrapper (`Launcher_Cfg_Section`):** A shared component providing a consistent header, icon, and responsive grid container.
### 6.2 3-Way State Logic
To manage screen real estate on varying laptop resolutions, all configuration sections utilize a 3-way visibility state:
- **`collapsed`**: Content is hidden.
- **`auto`**: Expanded by default, but automatically closes if another "auto" section is opened.
- **`pinned`**: Expanded and remains open regardless of other section interactions.
### 6.3 Technical Mode (`edit_mode`)
The UI dynamically filters fields based on the user's focus. Enabling Technical Mode (`$ae_loc.edit_mode`) reveals advanced diagnostic and writeable fields.
| Category | Standard View (Read-Only) | Technical Mode (Read/Write) |
| :--- | :--- | :--- |
| **Health** | Heartbeat, RAM Usage, Sync Stats | Hostname, IP List, Raw Device JSON |
| **OS Bridge** | Folder Buttons, Recording Toggle | Manual Terminal Commands, Reset Wallpaper |
| **Sync** | Sync Completion Status | Millisecond Timers, Cache Prefix Logic |
| **Update** | Current Version Status | Manual Update Paths, URL Overrides |
---
## 7. Implementation Reference (IPC Whitelist)
All functions below are exported from `src/lib/electron/electron_relay.ts` and safely
no-op when `window.aetherNative` is not present (i.e., in browser/non-native mode).
### Config & Info
- `get_device_config()` — Returns hydrated device settings injected by the native shell on startup.
- `get_device_info()` — Returns OS metadata, IP list, hostname, and path placeholders (`[home]`, `[tmp]`).
### File Cache
- `check_cache({cache_root, hash, hash_prefix_length?, verify_hash?})` — Verifies a file exists in the local hashed cache. `verify_hash: true` re-hashes to confirm integrity.
- `download_to_cache({url, cache_root, hash, api_key, account_id, hash_prefix_length?})` — Streams a file download to the hashed cache with SHA-256 integrity check. Stale `.tmp` files (older than 5 min) from crashed downloads are cleaned up automatically on each call.
- `copy_from_cache_to_temp({cache_root, hash, temp_root, filename, hash_prefix_length?})`**Preferred primitive.** Copies a cached file to temp and returns `{ success, path }`. The Svelte caller decides what to do next (run a script, open it, etc.).
- `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?, native_template?})` — Combines copy + launch in one call. Executes the provided `native_template` string after the file is copied to temp. If no template is supplied, treat it as an error and do not rely on Electron-side defaults.
> `hash_prefix_length` defaults to `2` throughout. Do not change without coordinating all devices — mismatched values create orphaned cache subdirectories.
### Shell & OS
- `open_folder(path)` — Opens a path in the OS file manager.
- `run_cmd({cmd, timeout?, return_stdout?})` — Async shell command execution.
- `run_cmd_sync({cmd, return_stdout?})` — Synchronous shell command execution.
- `run_osascript(script)` — Executes an AppleScript string. macOS only. **Hardened (2026-05-11):** writes script to a temp `.scpt` file; multi-line scripts and paths with special characters now work correctly. No shell escaping needed in the passed string.
- `kill_processes({process_name_li})` — Terminates processes by name. macOS/Linux: `pkill -f`. Windows: `taskkill /F`.
- `open_local_file_v2(path)` — Opens a file with its default OS application.
### Presentations (Phase 5)
- `launch_presentation({path, app?, os?})` — Platform-aware launcher. macOS: PowerPoint/Keynote via AppleScript. Linux: LibreOffice Impress. Resolves `[home]`/`[tmp]` placeholders.
- `control_presentation({app, action})` — Slide navigation (`next`/`prev`/`start`/`stop`) for PowerPoint or Keynote via AppleScript.
### System Management (Phase 5)
- `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Sets desktop wallpaper. Downloads from `url` (cached to `~/Library/Caches/OSIT/wallpaper/`) or applies a local `path`. `url_external` targets the projector/second display separately. macOS only in production; Linux returns a dev-mode preview payload without applying.
- `window_control({action, value?})` — Electron window management: maximize, minimize, fullscreen, kiosk.
- `set_display_layout({mode, configStr?})` — Mirror or extend displays via [`displayplacer`](https://github.com/jakehilborn/displayplacer). macOS only. Auto-detects via `displayplacer list`; `configStr` overrides auto-detection when set. Binary lookup order: bundled `resources/bin/displayplacer``/opt/homebrew/bin/` (Apple Silicon) → `/usr/local/bin/` (Intel). Requires `brew install displayplacer` on each venue Mac if not bundled.
- `power_control({action})` — Shutdown, reboot, or sleep the host machine. macOS + Linux.
- `manage_recording({action, options?})` — Aperture capture control (`start`/`stop`/`status`). macOS only.
- `open_external({url, app?})` — Opens a URL in Chrome, Firefox, or the default browser.
- `update_app(args)`**Stub only.** Downloads but does not install. Not yet functional.
- `list_tools()` — Returns a self-documenting manifest of all available native bridge functions.
### Path Placeholders
All paths passed to native handlers should use tokens rather than hardcoded OS paths:
- `[home]` — Resolved to the user's home directory by the native bridge.
- `[tmp]` — Resolved to the system temporary directory.
---
## 8. Launch Profiles and Native Templates (No-Rebuild File Handling)
This launcher uses two related concepts:
- **Launch Profile**: the Svelte-side config object keyed by file extension. A profile decides
which app to use, whether to extend or mirror displays, whether to use an explicit open
command, whether to run post-open automation, and how long to wait before running it.
- **Native Template**: the single AppleScript or shell command string handed to Electron after
Svelte resolves the profile. This is what Electron actually executes.
The Svelte launcher resolves a profile and then passes a native template string to
`launch_from_cache`. Electron only executes the template it receives. If Svelte has not
resolved a template yet, it should stop before IPC and surface a missing-profile error.
This keeps all fallback logic in Svelte, where it can be edited without rebuilding Electron.
The native layer should not invent or guess a default launch path.
The built-in defaults are organized as canonical profile names plus extension aliases. That
lets multiple file types share one profile without repeating the same app/script details.
The profile object also carries `post_delay_ms`, and a device-specific per-profile
`launch_profiles[profile].post_delay_ms` override can tune the delay without changing the bridge
contract. URL-based presentations remain a special pseudo-extension handled separately from
the cache open flow.
### Native Template Formats
| Format | Example |
| :--- | :--- |
| **AppleScript** (macOS) | Multi-line AppleScript string with `{{path}}` placeholder |
| **Shell command** | String prefixed with `shell:` — e.g. `shell:open "{{path}}"` |
The placeholder `{{path}}` is replaced with the full resolved path to the file in the temp
directory after the atomic copy from cache.
### Where to Configure
Launch profiles are resolved in priority order by `get_launch_profile()` in
`launcher_file_cont.svelte`:
1. **`event_device.data_json.launch_profiles`** — API-driven, per-device. Highest priority.
Set via the `event_device` record (Pres Mgmt → Device Management or direct DB edit).
2. **`$events_loc.launcher.launch_profiles`** — Local persistent config. Editable via the
Launcher config UI (planned) or direct `localStorage` manipulation.
If neither is set, the resolved native template is `null` and the launcher should not call
Electron until an explicit template is available.
Why: this avoids a second hidden source of truth. The profile map can evolve independently of
the executable string, and Electron stays a thin executor rather than a policy engine.
### Key Format
Keys are lowercase file extensions without the dot. A `"default"` key catches all
unrecognised extensions.
The JSON below illustrates the `native_template` emitted after profile resolution, not the
full Launch Profile object schema.
```json
// event_device.data_json.launch_profiles example
{
"launch_profiles": {
"pptx": "tell application \"Microsoft PowerPoint\"\n activate\n open (POSIX file \"{{path}}\")\n delay 3\nend tell\ntell application \"System Events\"\n keystroke return using command down\nend tell",
"key": "tell application \"Keynote\"\n activate\n open (POSIX file \"{{path}}\")\n delay 1\n start (front document)\nend tell",
"pdf": "shell:open \"{{path}}\"",
"default": "shell:open \"{{path}}\""
}
}
```
### AppleScript Execution — All Handlers Hardened (2026-05-11)
All AppleScript execution in the native shell now writes scripts to a temp `.scpt` file and
runs `osascript "<path>"` rather than the old `osascript -e "<inline>"` approach.
- **`run_osascript`** — hardened (2026-05-11, earlier batch)
- **`launch_from_cache`** — hardened (same batch)
- **`launch_presentation`** — hardened (2026-05-11, follow-up fix; was the last handler still using `-e`)
- **`control_presentation`** — uses single-line scripts with no path interpolation; `-e` is safe here and retained for simplicity
The `-e` approach breaks on (1) multi-line scripts and (2) file paths containing spaces,
quotes, or parentheses — common in conference presentation filenames.
### Not Exposed via Relay (intentional)
- `get_seed_config` / `get_jwt` — Exposed in the preload but not relayed to the UI. The JWT and seed are injected into the environment at startup; components should not call these directly.

View File

@@ -23,7 +23,7 @@ Key capabilities:
- **Badge scanning** — QR scan or text search (name, email, affiliations, badge ID)
- **Lead list** — filterable/sortable, per-exhibitor or per-staff-member view
- **Lead detail** — custom question responses, notes (rich text), priority flag, hide/unhide
- **Lead detail** — custom question responses, notes (rich text), priority boolean flag, hide/unhide
- **Export** — CSV/XLSX download of all leads for an exhibit
- **License management** — assign staff accounts (email + passcode) per max license count
- **Custom questions** — configurable per-exhibit follow-up questions (ratings, dropdowns, text)
@@ -109,7 +109,7 @@ The main lead management view.
Exhibit configuration and app settings.
**Admin Tools** (manager_access only):
- Payment status toggle (`priority` field)
- Payment status toggle (`priority` boolean field)
- Max licenses, small/large device counts
**Booth Profile** (all signed-in users):

View File

@@ -0,0 +1,139 @@
# Aether Events — Presentation Management
The Presentation Management module handles the full lifecycle of conference content: sessions, presentations, presenters, presentation files, and room/location assignments. It serves as the "Back Office" interface for event staff.
---
## Data Model
### Object Hierarchy
```text
Event
├── Event File (walk-in/out, hold slides for the whole event)
├── Location (physical room — assigned to Sessions, not the other way around)
├── Track (optional grouping; rarely used)
└── Session (time block; Location assigned here, but may be unset initially)
├── Session File (moderator slides, group/hold slides for this session)
└── Presentation (a talk within the session; must belong to exactly one Session)
└── Presenter (belongs to exactly one Presentation)
└── Presenter File (their slides/materials — the common case)
```
> **Import note:** When program data is initially imported (sessions, presentations,
> presenters), Locations are often not assigned to Sessions yet — rooms may not be
> finalized or the venue's room list may not be set up in Aether. Location assignment
> typically happens as a separate step once the room list is confirmed.
### Relationships
- **Session → Location:** Many-to-one. A Session is assigned to one Location; a Location
hosts many Sessions across the event timeline. Location may be null initially.
- **Presentation → Session:** Many-to-one. A Presentation belongs to exactly one Session.
A Session can have many Presentations (or none, for session-only setups).
- **Presenter → Presentation:** Many-to-one. A Presenter belongs to exactly one Presentation.
Optionally linked to an `event_person_id` for cross-referencing the person record.
- **Event File:** Can be attached at any level — Presenter, Presentation, Session,
Location, or Event. See the File Attachment Levels table.
### File Attachment Levels
Files (`event_file`) can be attached at five levels:
| Level | When Used | Typical Content |
|---|---|---|
| **Presenter** | 99% of the time for individual speakers | Their PowerPoint/PDF/video |
| **Session** | Moderator slides; group/hold content for a specific session | "Session 3 — Group Discussion.pptx" |
| **Location** | Walk-in/out or hold slides for a room across all sessions | Looped PPTX playing between sessions |
| **Event** | Walk-in/out or hold slides used everywhere | Looped PPTX; branding overlay |
| **Presentation** | File attached to the presentation record itself (less common) | Varies |
### Key Objects
| Object | Table | Purpose |
|---|---|---|
| Session | `event_session` | Time block; Location and datetime range assigned here |
| Location | `event_location` | Physical room |
| Presentation | `event_presentation` | A talk within a session; belongs to exactly one Session |
| Presenter | `event_presenter` | Person linked to exactly one Presentation |
| Event Person | `event_person` | Person record within the event context |
| Event File | `event_file` | Uploaded file; attached at Presenter, Presentation, Session, Location, or Event level |
---
## Client Setup Variation
There are no rigid "modes" — events are configured with as much or as little structure
as needed. The platform handles the full range:
**Minimal setup (BGH):**
Sessions have room and time info. No Presentations or Presenters defined.
Staff upload files directly at the session or location level onsite.
**Mid-range setup:**
Sessions defined with named Presentations. Presenters may or may not be tracked.
Mix of pre-uploaded and onsite files. QR codes may be used for quick session/presenter lookup.
**Full setup (LCI):**
Sessions, Presentations, Presenters all defined and managed. External ID labeling
(e.g., "LCI Member ID"). Agreement tracking for presenters. Files managed per-presenter.
The config that drives this is `event.mod_pres_mgmt_json` — see the Configuration section.
---
## Configuration — `mod_pres_mgmt_json`
The event's Presentation Management behavior is controlled by `event.mod_pres_mgmt_json`.
### Convention
| Prefix | Default state | Meaning |
|---|---|---|
| `hide__` | `false` = visible | Feature is ON by default; set `true` to suppress |
| `show__` | `false` = hidden | Feature is OFF by default; set `true` to enable |
### Common Config Keys
| Key | Default | Notes |
|---|---|---|
| `lock_config` | `false` | `true` = force remote→local sync; prevents user overrides of local config |
| `hide__session_code` | `false` | Hide session code column/field |
| `hide__session_description` | `false` | Hide session description field |
| `hide__session_location` | `false` | Hide location field on session view |
| `hide__session_datetime` | `false` | Hide datetime fields |
| `hide__presentation_code` | `false` | Hide presentation code |
| `hide__presenter_code` | `false` | Hide presenter code |
| `hide__location_code` | `false` | Hide location code |
| `show__launcher_link` | `false` | Show direct Launcher link in session view |
| `show__session_qr` | `false` | Show QR code for session (SRR lookup) |
| `show__presenter_qr` | `false` | Show QR code for presenter (SRR lookup) |
| `label__person_external_id` | `null` | Override label for external ID field (e.g., `"Member ID"`) |
| `label__session_poc_name` | `null` | Override label for session POC (e.g., `"Champion"`) |
| `file_purpose_option_kv` | `{}` | Key-value map of file purpose options (e.g., `{"ppt": "PowerPoint", "pdf": "PDF"}`) |
---
## Route Map (Administration)
| URL | Purpose |
|---|---|
| `/events/[id]/pres_mgmt` | Overview — sessions list, search, filter by location |
| `/events/[id]/pres_mgmt/config` | Config editor (admin only) |
| `/events/[id]/session/[session_id]` | Session detail — files, presentations, timing, alert |
| `/events/[id]/presenter/[presenter_id]` | Presenter detail — bio, files, agreement, alert |
| `/events/[id]/location/[location_id]` | Location detail — session schedule for this room, alert |
| `/events/[id]/locations` | All locations list |
| `/events/[id]/reports` | Reports — sessions, presenters, files |
---
## Access Levels
| Feature | Minimum Access |
|---|---|
| View pres_mgmt overview | `authenticated_access` |
| Upload files | `authenticated_access` |
| Edit sessions / presentations | `trusted_access` |
| Edit config | `administrator_access` + `edit_mode` |
| Device management | `administrator_access` |

View File

@@ -124,7 +124,7 @@ the MODULE doc TODO list was stale. `duplex` is in `properties_to_save`; v2 badg
- `ae_comp__badge_print_controls.svelte` — Identity card at top, pronouns moved to attendee section,
"Staff adjustments" divider before badge_type field.
- `print_list/+page.svelte` — Updated to import v2.
- `ae_comp__badge_obj_view.svelte` (v1) — **Moved to ~/tmp/gemini_trash/**
- `ae_comp__badge_obj_view.svelte` (v1) — **Moved to ~/tmp/agents_trash/**
**Kiosk UX improvements (2026-03-12):**
- Print page header: cleaner, shows name + "Ready"/"Printed N×" status chip, event name.

View File

@@ -1,180 +0,0 @@
# Aether Events Launcher: Native Electron Integration
> **Status:** Operational / Phase 5 Implementation
> **Last Updated:** 2026-03-11
> **Primary Platform:** macOS (Darwin)
> **Fallback Platform:** Linux / Windows
## 1. Overview
The Aether Events Launcher utilizes an Electron-based "Native Shell" to provide OS-level capabilities that are normally restricted by browser sandboxing. This enables persistent file caching, direct control of presentation software (Keynote, PowerPoint), and hardware telemetry.
### Operational Modes
| Mode | Purpose | File Handling |
| :--- | :--- | :--- |
| **Default** | Standard web browser access. | Direct downloads; no local caching. |
| **Onsite** | Web access on event networks. | Faster polling; browser-based file management. |
| **Native** | Dedicated Podium Kiosk (Electron). | Full background pre-caching; atomic safe-handover. |
---
## 2. Architecture: The Three-Layer Bridge
The integration is built on a decoupled three-layer communication model to ensure security and cross-platform flexibility.
### 2.1 Layer 1: The Engine (Main Process)
- **Repo:** `~/OSIT_dev/aether_app_native_electron/` (separate git repo)
- **File:** `aether_app_native_electron/src/main/*.ts`
- **Role:** Performs the heavy lifting (Filesystem, Shell, AppleScript).
- **Responsibilities:**
- Managing the **Hashed Cache** directory.
- Executing `osascript` intents for presentation control.
- Spawn/Kill process management.
### 2.2 Layer 2: The Gatekeeper (Preload Script)
- **Namespace:** `window.aetherNative`
- **Role:** Securely exposes whitelisted IPC channels to the Renderer.
- **Standards:** Uses `contextBridge.exposeInMainWorld` to prevent arbitrary code execution.
### 2.3 Layer 3: The Messenger (SvelteKit Relay)
- **File:** `src/lib/electron/electron_relay.ts`
- **Role:** Provides a clean, typed API for Svelte components.
- **Responsibilities:**
- Mapping `camelCase` UI triggers to `snake_case` IPC calls.
- Implementing "Smart Fallbacks" (e.g., resolving `[home]` placeholders if the bridge is partially hydrated).
---
## 3. The "Zero-Config" Lifecycle
To support rapid onsite deployment, the native app requires zero manual setup.
1. **Seed:** On launch, the Main process reads a local `seed.json` (Device ID + API Key).
2. **Identity:** Calls `GET /v3/data_store/code/{device_code}` or `GET /v3/crud/event_device/{id}` to pull operational context.
3. **Hydrate:** Authenticates with the Aether V3 API and injects the **JWT** and **Device Config** into the UI environment.
4. **Launch:** Navigates the SvelteKit frontend directly to the assigned Event Launcher route.
---
## 4. Podium Reliability Protocol
The system is designed to ensure that a presentation never fails due to network instability.
### 4.1 Hashed Cache Pattern
Files are stored persistently using their SHA-256 hash to prevent filename collisions and handle versioning.
- **Root:** `~/Library/Caches/OSIT/file_cache/`
- **Subdirectory:** First 2 characters of hash (e.g., `ab/`)
- **Filename:** `{hash}.file`
### 4.2 Background Sync (File Warming)
When a user navigates to a session in the Launcher UI, the `LauncherBackgroundSync` component:
1. Extracts all `event_file_id` values for that session.
2. Checks the native cache via `aetherNative.check_cache`.
3. Triggers background downloads for missing files via `aetherNative.download_to_cache`.
### 4.3 Safe Handover (Launch Sequence)
When a user clicks "Open", the system follows a non-destructive sequence:
1. **Verify:** Confirm hash exists in the permanent cache.
2. **Copy:** Create an atomic copy in the system `[tmp]` directory.
3. **Restore:** Rename the copy to its original filename (e.g., `Abstract_101.pptx`).
4. **Execute:** Launch the file via the OS.
---
## 5. Automation & Actuators (Phase 5)
The native shell provides specialized handlers for controlling the "Podium Experience."
### 5.1 Presentation Acts
| Action | Handler | Actuator (macOS) |
| :--- | :--- | :--- |
| **Launch** | `launch_presentation` | `open` or `osascript` (slideshow start) |
| **Control** | `control_presentation` | `osascript` (next/prev slide) |
| **Clean Up**| `kill_processes` | `killall -INT` (graceful exit) |
### 5.2 System Management
- **Telemetry:** Pushes `cpu_usage`, `memory_free_gb`, and `foreground_app` via heartbeats using the `get_device_info` relay.
- **Self-Update (Roadmap):** Plan to monitor Syncthing `admin_share` for newer `.app` versions and perform atomic swaps.
### 5.3 Implemented Actuators (Phase 5 Complete)
- **Recording:** `manage_recording({action})` — Aperture session capture (`start`, `stop`, `status`). macOS only.
- **Display Layouts:** `set_display_layout({mode})` — Mirror / Extend via `displayplacer`. macOS only.
- **Power Control:** `power_control({action})` — Shutdown, reboot, sleep. macOS + Linux.
- **Window Control:** `window_control({action})` — Maximize, minimize, fullscreen, kiosk mode.
- **Wallpaper:** `set_wallpaper({path})` — macOS (AppleScript) + Linux (gsettings).
> **Note:** `update_app` is implemented as a stub — downloads but does not install. Not yet functional for end users.
---
## 6. Launcher Configuration & Management
The Launcher features a standardized, responsive configuration interface designed for onsite technical management.
### 6.1 UI Architecture
- **Tabbed Navigation:** Categorized into System, Sync, and General settings.
- **Section Wrapper (`Launcher_Cfg_Section`):** A shared component providing a consistent header, icon, and responsive grid container.
### 6.2 3-Way State Logic
To manage screen real estate on varying laptop resolutions, all configuration sections utilize a 3-way visibility state:
- **`collapsed`**: Content is hidden.
- **`auto`**: Expanded by default, but automatically closes if another "auto" section is opened.
- **`pinned`**: Expanded and remains open regardless of other section interactions.
### 6.3 Technical Mode (`edit_mode`)
The UI dynamically filters fields based on the user's focus. Enabling Technical Mode (`$ae_loc.edit_mode`) reveals advanced diagnostic and writeable fields.
| Category | Standard View (Read-Only) | Technical Mode (Read/Write) |
| :--- | :--- | :--- |
| **Health** | Heartbeat, RAM Usage, Sync Stats | Hostname, IP List, Raw Device JSON |
| **OS Bridge** | Folder Buttons, Recording Toggle | Manual Terminal Commands, Reset Wallpaper |
| **Sync** | Sync Completion Status | Millisecond Timers, Cache Prefix Logic |
| **Update** | Current Version Status | Manual Update Paths, URL Overrides |
---
## 7. Implementation Reference (IPC Whitelist)
All functions below are exported from `src/lib/electron/electron_relay.ts` and safely
no-op when `window.aetherNative` is not present (i.e., in browser/non-native mode).
### Config & Info
- `get_device_config()` — Returns hydrated device settings injected by the native shell on startup.
- `get_device_info()` — Returns OS metadata, IP list, hostname, and path placeholders (`[home]`, `[tmp]`).
### File Cache
- `check_hash_file_cache({cache_root, hash, hash_prefix_length?})` — Verifies a file exists in the local hashed cache.
- `download_to_cache({url, cache_root, hash, api_key, account_id, hash_prefix_length?})` — Streams a file download to the hashed cache with SHA-256 integrity check.
- `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?})` — Atomic "Safe Handover": copy from cache → tmp → rename → execute.
- `cleanup_tmp_files({cache_root, max_age_minutes?})` — Removes stale `*.tmp` download artifacts. Default: 1440 min (24h). Called at launcher startup.
> `hash_prefix_length` defaults to `2` throughout. Do not change without coordinating all devices — mismatched values create orphaned cache subdirectories.
### Shell & OS
- `open_folder(path)` — Opens a path in the OS file manager.
- `run_cmd({cmd, timeout?, return_stdout?})` — Async shell command execution.
- `run_cmd_sync({cmd, return_stdout?})` — Synchronous shell command execution.
- `run_osascript(script)` — Executes an AppleScript string. macOS only.
- `kill_processes({process_name_li})` — Gracefully terminates processes by name.
- `open_local_file_v2(path)` — Opens a file with its default OS application.
### Presentations (Phase 5)
- `launch_presentation({path, app?, os?})` — Platform-aware launcher. macOS: PowerPoint/Keynote via AppleScript. Linux: LibreOffice Impress. Resolves `[home]`/`[tmp]` placeholders.
- `control_presentation({app, action})` — Slide navigation (`next`/`prev`/`start`/`stop`) for PowerPoint or Keynote via AppleScript.
### System Management (Phase 5)
- `set_wallpaper({path})` — Sets desktop wallpaper. macOS (AppleScript) + Linux (gsettings).
- `window_control({action, value?})` — Electron window management: maximize, minimize, fullscreen, kiosk.
- `set_display_layout({mode, configStr?})` — Mirror or extend displays via displayplacer. macOS only.
- `power_control({action})` — Shutdown, reboot, or sleep the host machine. macOS + Linux.
- `manage_recording({action, options?})` — Aperture capture control (`start`/`stop`/`status`). macOS only.
- `open_external({url, app?})` — Opens a URL in Chrome, Firefox, or the default browser.
- `update_app(args)`**Stub only.** Downloads but does not install. Not yet functional.
- `list_tools()` — Returns a self-documenting manifest of all available native bridge functions.
### Path Placeholders
All paths passed to native handlers should use tokens rather than hardcoded OS paths:
- `[home]` — Resolved to the user's home directory by the native bridge.
- `[tmp]` — Resolved to the system temporary directory.
### Not Exposed via Relay (intentional)
- `get_seed_config` / `get_jwt` — Exposed in the preload but not relayed to the UI. The JWT and seed are injected into the environment at startup; components should not call these directly.

View File

@@ -83,6 +83,28 @@ This gives session expiry without a network call on every page load.
**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
### Backend Agent Follow-Up
If the backend team revisits this area, keep the next round focused on narrowing escape hatches rather than adding new ones:
1. Audit every `x-no-account-id` use and decide whether it is still required for bootstrap, public delivery, or a global-default fallback.
2. Prefer JWT-backed auth once a session exists; do not add new transport-level bypass paths for authenticated UI flows.
3. Mark any remaining bypass-only helper as temporary and add a removal target.
4. Plan the eventual removal of `access_code_kv_json` from public bootstrap payloads once passcode auth is fully deployed.
### Frontend special-case endpoints to review
These are the current frontend-facing exceptions that the backend work should assume are special-cased. None require a frontend/client code change today, but some are intentionally temporary.
| Frontend path / helper | Status | Notes |
| --- | --- | --- |
| `src/routes/+layout.ts` | Keep | Bootstrap site-domain lookup before account context is known. |
| `src/routes/manifest.webmanifest/+server.ts` | Keep | Public PWA branding lookup; bootstrap key only. |
| `src/lib/ae_core/ae_core__site.ts` | Keep | Cache-first site-domain bootstrap path. Still a bootstrap-only special case. |
| `src/lib/ae_api/api_get__data_store.ts` + `src/lib/ae_core/core__data_store.ts` + `src/lib/elements/element_data_store.svelte` | Temporary | Global-default fallback. Target state is JWT-backed account-scoped access only. |
| `src/lib/ae_core/ae_core_functions.ts` | Remove candidate | Legacy site-domain helper with forced no-account scope. |
| `src/routes/testing/+page.svelte` | Dev-only | Useful for trace testing; do not add to any production allowlist. |
**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.
**File:** `aether_api_fastapi/app/routers/api.py`

View File

@@ -1,7 +1,7 @@
# Aether Journals UI Update (2026)
> **Status:** 🚧 Phase 4 Active (Security/Encryption Blockers remain; Style pass complete)
> **Last Updated:** 2026-03-06
> **Status:** 🚧 Phase 4 Active (Security/Encryption Blockers remain; Journal Entry config rework in progress)
> **Last Updated:** 2026-05-05
> **Primary Agent:** Frontend SvelteKit Agent
## 1. Project Overview
@@ -72,6 +72,10 @@ This document outlines the modernization of the Journals module UI in the Svelte
- [x] Implement Auto-Save toggle and visual status indicators.
- [x] Extract decryption workflow to non-reactive helper.
- [x] **Standardize Configuration Modals:** Refactored Module, Journal, and Entry configuration into a unified tabbed UI.
- [x] **Journal Entry Config cleanup:** Summary now lives in Metadata; Alert lives in its own Alerts & Messaging section; Privacy Flags is visibility-only; Admin controls are split out and gated to trusted-access and above.
- [x] **Shared Flags widget:** `AE_Object_Flags` now shows visible button text and hover titles instead of icon-only controls.
- [x] **Modal sizing:** Entry config modal now expands to viewport height instead of stopping at a fixed 60vh body cap.
- [x] **Delete/Remove behavior:** Entry config Admin section now uses the real delete helper. Managers/admins see Delete (hard delete); trusted access sees Remove (disable semantics).
- [x] **RESOLVED:** Decryption workflow stability (Fixed via dependency isolation).
- [x] **Style Standardization (2026-03-06):** Full Skeleton v4 `preset-*` class pass across all 17 journal components. See style token table in Lessons Learned below.
- [x] **Dark mode fixes:** Entry content hover, journal view section/description background and text colors.
@@ -100,6 +104,7 @@ We have established a unified design language for configuration interfaces and a
* **Close button:** Always use `dismissable={false}` on the `<Modal>` and add an explicit `<button>` with `<X>` inside the `{#snippet header()}` so placement is fully in our control. The `flex-1` class on the `<h3>` pushes it right.
* **Tabs:** Center-aligned `btn btn-sm` with `preset-filled-primary` (active) / `preset-tonal-surface` (inactive).
* **Icons:** Every tab and primary action should have a Lucide icon for better scannability.
* **Button titles:** Any button that uses icon+text or icon-only must include a descriptive `title` for hover clarity.
* **Explicit Persistence:** Follow "Edit working copy → Save Changes" pattern to prevent accidental store/API churn.
#### Skeleton v4 Style Token Reference (Journals = canonical example)
@@ -111,6 +116,7 @@ We have established a unified design language for configuration interfaces and a
| Success (confirmed save) | `btn preset-filled-success` |
| Warning (caution action) | `btn preset-tonal-warning hover:preset-filled-warning-500` |
| Error / danger (delete, force reset) | `btn preset-tonal-error hover:preset-filled-error-500` |
| Warning action (remove/disable) | `btn preset-tonal-warning hover:preset-filled-warning-500` |
| Active tab | `preset-filled-primary` |
| Inactive tab | `preset-tonal-surface` |
| Icon button | `btn-icon btn-icon-sm preset-tonal-surface` |
@@ -143,6 +149,14 @@ Svelte 5 state is backed by Proxies.
* **The Problem:** Using `JSON.parse(JSON.stringify(proxy))` can sometimes trigger unexpected behavior or loops when used inside a reactive context.
* **The Fix:** Implement a manual `deep_copy` helper or selective property assignment when syncing "Original" vs "Temporary" state. This ensures `orig_entry_obj` is a plain JS object, making the `has_unsaved_changes` check stable.
### 5. Journal Entry Config Layout Notes
The Entry Config modal now follows a stricter section grammar:
* `Metadata` contains category, tags, summary, archive date, and template.
* `Status & Security` contains enabled/hidden/priority/sort.
* `Visibility & Audience` contains only visibility/audience toggles.
* `Alerts & Messaging` contains alert flag + alert message.
* `Admin` is gated to trusted access and above, and is the only place for notes plus delete/remove actions.
### 3. Concurrency Locking (`is_processing`)
* **The Problem:** Decryption (Async) and Auto-Save (Debounced Async) can fire nearly simultaneously.
* **The Fix:** Use a simple `is_processing` boolean flag. If any async workflow is active, block others from starting and prevent the `has_unsaved_changes` derived rune from reporting `true`.

View File

@@ -1,7 +1,7 @@
# Project: CRUD V3 Final Migration
> **Status:** Active / In Progress
> **Last Updated:** 2026-01-20
> **Status:** 🟡 Surgical Cleanup (90% Complete — Events Module Fully Migrated)
> **Last Updated:** 2026-05-21
> **Goal:** Eliminate all dependency on legacy API wrappers (`create_ae_obj_crud`, `get_ae_obj_id_crud`, etc.) and ensure 100% adoption of the V3 Standard (`/v3/crud/...`).
---
@@ -21,23 +21,23 @@ While the **Journals** and **Identity (User/Account)** modules have been success
The following files have been identified as using legacy CRUD wrappers.
### 🔴 High Priority: Events Module
The entire `ae_events` library is heavily dependent on legacy `v2` list and `v1` CRUD wrappers.
- [ ] `src/lib/ae_events/ae_events__event_session.ts`
- [ ] `src/lib/ae_events/ae_events__event_presenter.ts`
- [ ] `src/lib/ae_events/ae_events__event_presentation.ts`
- [ ] `src/lib/ae_events/ae_events__event_location.ts`
- [ ] `src/lib/ae_events/ae_events__event_badge_template.ts`
- [ ] `src/lib/ae_events/ae_events__event_device.ts`
- [x] `src/lib/ae_events/ae_events__event_session.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__event_presenter.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__event_presentation.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__event_location.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__event_badge_template.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__event_device.ts` (Migrated 2026-01-30)
- [x] `src/lib/ae_events/ae_events__exhibit.ts` (Migrated 2026-01-28)
- [ ] `src/lib/ae_events/ae_events__event_file.ts`
- [x] `src/lib/ae_events/ae_events__event_file.ts` (Migrated 2026-01-30)
### 🟠 Medium Priority: Core & Sponsorships
Legacy patterns persisting in core logic and config modules.
- [ ] `src/lib/ae_sponsorships/ae_sponsorships_functions.ts`
- [ ] `src/lib/ae_core/core__hosted_files.ts` (Uses `get_ae_obj_id_crud`)
- [ ] `src/lib/ae_core/core__site.ts` (Uses `get_ae_obj_id_crud`)
- [x] `src/lib/ae_core/core__hosted_files.ts` (Migrated 2026-01-20)
- [x] `src/lib/ae_core/core__site.ts` (Migrated 2026-01-26; bootstrap path uses V3 `search_ae_obj` by `fqdn`)
- [x] `src/lib/ae_core/core__site_domain.ts` (Retired 2026-06-02; helper removed after bootstrap migration to `core__site.ts`)
- [ ] `src/lib/ae_core/ae_core_functions.ts` (STILL USES `get_ae_obj_id_crud` / `update_ae_obj_id_crud`)
- [ ] `src/lib/ae_core/core__country_subdivisions.ts`
- [ ] `src/lib/ae_core/core__time_zones.ts`
- [ ] `src/lib/ae_core/core__countries.ts`
@@ -48,9 +48,10 @@ Specific UI components that make direct API calls instead of using store functio
- [ ] `src/lib/elements/element_data_store.svelte` (Direct `create_ae_obj_crud`)
- [x] `src/lib/elements/element_data_store_v2.svelte`
- [ ] `src/routes/events/[event_id]/event_page_menu.svelte`
- [ ] `src/routes/events/[event_id]/(pres_mgmt)/session/ae_comp__event_session_alert.svelte`
- [x] `src/routes/events/[event_id]/(pres_mgmt)/session/ae_comp__event_session_alert.svelte` (Migrated to `update_ae_obj`)
- [ ] `src/routes/events/ae_comp__event_session_obj_li.svelte`
- [ ] `src/routes/idaa/(idaa)/recovery_meetings/ae_idaa_comp__event_obj_id_edit.svelte`
- [ ] `src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/ae_comp__event_presenter_form_agree.svelte` (STILL USES `update_ae_obj_id_crud`)
---

View File

@@ -0,0 +1,58 @@
# IDAA Recovery Meetings: UI/UX Improvement Roadmap
This document outlines proposed enhancements for the IDAA Recovery Meeting module. The goal is to make it easier for members to find and attend meetings, especially on mobile devices, while providing IDAA staff with better tools to manage meeting data quality.
## 🏆 The "Big Wins" (Highest Member Impact)
### 1. Automatic Timezone Conversion
* **The Problem:** Meetings currently show their "native" time (e.g., 7:00 PM Central). Members must manually calculate the time for their own location.
* **The Fix:** The app will automatically detect the member's local timezone and show a converted time side-by-side (e.g., *"7:00 PM Central — 8:00 PM your time"*).
### 2. "Live Now" & "Todays Meetings"
* **The Fix:**
* **Live Now:** A high-visibility green "LIVE" badge will pulse next to meetings currently in progress.
* **Todays Section:** A dedicated section at the very top of the list will show only meetings happening today, sorted by time, so members don't have to scroll through the full 140+ meeting list.
### 3. Clearer Meeting Schedules
* **The Problem:** Days of the week are currently listed as a flat string (Sunday Monday Wednesday).
* **The Fix:** Convert schedules into natural language one-liners: *"Mondays, Wednesdays, and Fridays at 7:00 PM."* This is much faster for the human eye to scan.
### 4. Favorites ("My Meetings")
* **The Fix:** Members can "Star" their regular meetings. These favorites will be pinned to the top of their list for one-tap access every week.
### 5. "Add to Calendar"
* **The Fix:** A button to automatically add a recurring meeting to a members Google, Apple, or Outlook calendar, including the Zoom/Jitsi link in the calendar event description.
---
## 🛡️ Staff Tools & Data Quality
### 6. "Confirmed Only" Default View
* **The Strategy:** To encourage meeting chairs to keep their information current, we propose defaulting the list to show **only** meetings confirmed by the Central Office.
* **Member Benefit:** Higher confidence that the meeting they are about to join is active and the link is correct.
* **Staff Benefit:** Creates a natural incentive for chairs to contact IDAA to get "Verified," as unverified meetings would require an extra click to see.
### 7. Mobile-Friendly "Not Confirmed" Explanations
* **The Problem:** On mobile, the warning badge for unconfirmed meetings doesn't explain *why* it's there or *how* to fix it.
* **The Fix:** Tapping the badge will show a simple popup: *"This meeting hasn't been verified recently. If you are the chair, please email info@idaa.org to confirm."*
---
## 📱 Ease-of-Use & Mobile Polishing
### 8. Prominent "Join" Buttons & Easy Sharing
* **The Fix:** For virtual meetings, we will move the "Join Zoom" button to a prominent, full-width position at the top of the card. We will also add a "Share" button so members can easily text a meeting link to a sponsee.
### 9. Simplified "Quick-Filter" Chips
* **The Fix:** Instead of small checkboxes, we will add large "Chips" (buttons) for common filters: `[🖥 Virtual]` `[🏠 In-Person]` `[🩺 IDAA]` `[Caduceus]`. These are much easier to tap on a phone screen.
### 10. Intelligent "No Results" Guidance
* **The Problem:** If a member filters too narrowly (e.g., "Caduceus meetings in Hawaii on Tuesdays"), they just see a blank screen.
* **The Fix:** A helpful prompt will appear: *"No meetings found for these filters. [Clear all filters →]"* to prevent members from thinking the app is broken.
---
### Next Steps
1. **Feedback:** Staff identifies which 34 items are the highest priority for the next update.
2. **Prototype:** We implement the high-priority items in the testing environment for staff review.
3. **Deployment:** Changes are pushed live to the IDAA website.

View File

@@ -1,207 +1,101 @@
# Frontend Agent Task List
> Use this file to track steps for complex features or bug fixes.
> **Status:** Stable — ongoing development.
> **Scope:** Active/open work only. Completed detail lives in archive files.
## 🔴 CMSC Charlotte — May 27 (Presentation Management)
**Post-show hardening only**
## 🚧 Upcoming High Priority
### [Stores] Svelte 4 → Svelte 5 State Migration (prerequisite for Phase 2c)
The app uses `svelte-persisted-store` (Svelte 4 store contract) for all core persisted state
(`ae_loc`, `idaa_loc`, `ae_api`, `ae_sess`, etc.). In Svelte 5 `$effect`, reading **any field**
of a Svelte 4 store subscribes to the **entire store** — coarse-grained reactivity. This is the
root cause of the IDAA Novi re-auth bug (2026-03-30): unrelated `$ae_loc` writes (e.g. iframe
height, SWR cfg reload) triggered the Novi verification effect repeatedly.
Migration target: replace `svelte-persisted-store` with Svelte 5 `$state`-based persistence
(e.g. `runed` `PersistedState`, or a lightweight custom wrapper). This gives fine-grained
reactivity — only effects that actually read a changed field re-run.
**Phased approach (do NOT do all at once):**
- [ ] **Phase A — Project plan + wrapper decision:** Write `PROJECT__Stores_Svelte5_Migration.md`.
Decide: `runed` library vs. custom `$state` + localStorage wrapper. Audit all store consumers.
Identify stores in priority order. Estimate blast radius per store.
- [ ] **Phase B — Core auth stores (highest impact, start here):**
- `ae_loc` (persisted) — auth flags, site cfg, UI state; ~471 consumer sites across 150+ files
- `idaa_loc` (persisted) — Novi auth, IDAA query prefs
These two cause the most reactive noise. Migrating them also unlocks Phase 2c (separate `ae_auth`
store) since the callsite sweep is now required anyway.
- [ ] **Phase C — Remaining persisted stores:**
- `ae_api` (persisted) — API config / JWT
- `ae_events_stores` persisted entries (badges, launcher, leads, pres_mgmt loc stores)
- [ ] **Phase D — Non-persisted writable stores:**
- `ae_sess`, `idaa_sess`, `slct`, `slct_trigger`, `ae_auth_error`, `ae_trig`, `ae_snip`, etc.
- Lower urgency (no localStorage churn), but fine-grained reactivity still beneficial.
- [ ] **Phase E — Phase 2c (unblocked after B):** Split `ae_loc` into `ae_auth` + `ae_app`
(see entry below — ~471 callsites, but sweep is cheap once already touching every consumer).
**Project plan doc needed:** Yes — scope is app-wide. Do NOT start Phase B without Phase A.
- [ ] **[Launcher/Electron] Wallpaper reliability (post-CMSC)**
- [ ] Use timestamp/randomized temp filename so macOS always sees a new path.
- [ ] Add resilient reconciliation loop or event-driven reapply on display topology changes.
---
### [Stores] Refactor — Phase 2c (deferred)
Phases 1, 2a, 2b are complete (see ✅ Completed below). One phase remaining:
## 🔴 Axonius DC — June 9 (Badge Printing)
**Setup/Registration:** June 8 | **Show:** June 9
- [ ] **Phase 2c — Actual separate stores (`ae_auth`, `ae_app`):** Requires touching ~471
`$ae_loc.*` auth-field read sites across 150+ files. Deferred until a Svelte runes migration
of the store layer itself (touching every component anyway makes the callsite sweep cheap).
- [x] **[Badges] Epson C3500 fanfold badge layout** — `badge_4x6_fanfold` layout CSS created,
wired, and documented. First live use: Axonius Adapt DC, June 9, 2026. (2026-05-15)
### [Backend] Join event_location_id onto event_presenter API view
The `event_presenter` object currently has `event_session_id` but not `event_location_id`.
When navigating from the Presenter View to the Launcher, the frontend has to do a secondary
session lookup to discover the location (magic redirect in launcher base `+page.svelte`).
Joining `event_session.event_location_id` into the presenter view/response would let the
frontend pass the location directly in the Launcher URL without the extra lookup.
- [x] Backend: added `event_location_id` (and `event_location_id_random`) to the `event_presenter` view or API response (2026-04-09)
- [x] Frontend: updated `ae_EventPresenter` type and `properties_to_save`; now pass as `events__launcher_id` in `presenter_page_menu.svelte` (2026-04-09)
---
## 🚧 V3 CRUD Migration (Surgical Cleanup)
Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy wrappers.
- [ ] **[Core] Legacy Utility Helpers** — Refactor `ae_core_functions.ts` to use V3 helpers.
- [ ] **[Cleanup] Delete Legacy Wrappers** — Once all callsites are migrated, remove
`src/lib/ae_api/api_get__crud_obj_id.ts` and the legacy exports from `api.ts`.
---
## 🚧 High Priority Workstreams
### [TypeScript] svelte-check hidden errors — discovered 2026-03-27
**HOW WE FOUND THIS:** The `@lucide/svelte` 0.577.0 update (2026-03-10) dropped `class` from
`IconProps`. Fixing it required a `declare module '@lucide/svelte'` augmentation. That
augmentation was mistakenly placed in `app.d.ts`, which is a *script-context* declaration file
(no `export {}`). In that context, `declare module` is an **ambient replacement**, not a merge —
it wiped all icon exports from svelte-check's view, surfacing 1368 previously hidden errors.
Once moved to `src/lucide-augment.d.ts` (a proper module file with `export {}`), the masking
lifted and the real pre-existing errors became visible.
### [Stores] Svelte 4 → Svelte 5 State Migration
The app uses `svelte-persisted-store` (coarse reactivity). Migration target: replace with Svelte 5
`$state`-based persistence for fine-grained updates.
**Lesson:** A broken ambient declaration can silently hide unrelated errors. If svelte-check
suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a package's types.
- [ ] **Phase A — Project plan + wrapper decision:** Write `PROJECT__Stores_Svelte5_Migration.md`.
- [ ] **Phase B — Core auth stores (highest impact):** `ae_loc`, `idaa_loc`.
- [ ] **Phase C — Remaining persisted stores:** `ae_api`, `ae_events_stores`.
- [ ] **Phase D — Non-persisted writable stores:** `ae_sess`, `slct`, `ae_snip`, etc.
**Current state (2026-03-31):** 32 errors, 0 warnings — all `ModalProps.children`.
### [Data Layer] IDB sorting + content version rollout
Sorting baseline is now `build_tmp_sort` (ASC chain, no `.reverse()` on tmp-sort lists).
- [ ] **[flowbite-svelte] `ModalProps.children` — 31 errors across 26 files.** The flowbite-svelte
`Modal` component API changed; `children` is no longer a direct prop (now Svelte snippet-based).
Affected files span journals, pres_mgmt, events/settings, and IDAA archives.
Run `npx svelte-check 2>&1 | grep ModalProps` to get the current list.
Fix pattern: replace `children` prop binding with Svelte snippet syntax per flowbite-svelte docs.
**⚠️ Exception:** `ae_events__event.ts` and `ae_events__event_session.ts` use **legacy encoding**
(`priority ? 1 : 0`, priority=true→`'1'`). Their sort comparators must remain **descending**
until the modules are migrated to `build_tmp_sort`. `ae_events__event_presentation.ts` already
uses `build_tmp_sort` (overrides generic encoding in its `specific_processor`). See
`CLIENT__IDAA_and_customized_mods.md` → "Sort Encoding" for full table.
- [ ] **[IDAA] Make `contact_li_json_ext` searchable — Recovery Meeting contact search (2026-04-08)**
Members cannot search for meetings by contact name or email. `contact_li_json` data is not
included in `default_qry_str` and MariaDB cannot substring-search a JSON longtext directly.
The `event` table already has `contact_li_json_ext` (STORED GENERATED, indexed) to work around this.
- [ ] **[IDB Sort] Migrate `ae_events__event.ts` to `build_tmp_sort`** — requires bumping
`IDB_CONTENT_VERSIONS.events.event` (currently v3) and switching all event sort comparators
to ascending. Check all pages that sort events before doing this.
- [ ] **[IDB Sort] Roll out to `ae_events__event_session`** after sort behavior review.
- [ ] **[IDB Sort] Roll out to `ae_events__event_presenter`** after sort behavior review.
- [ ] **[IDB Sort] Roll out to `ae_events__event_location`** after sort behavior review.
- [ ] **[IDB Sort] Roll out to `ae_core__person` + `ae_core__account`** after sort behavior review.
- [ ] **[IDB Version] Roll out to `db_events.ts`** (session, presenter, badge, etc.).
- [ ] **[IDB Version] Roll out to `db_core.ts`** (site_domain, person, user).
**Backend (blocked on this first):** Add `contact_li_json_ext` to the searchable fields
whitelist for the `event` object type — likely a one-line change in `ae_obj_types_def.py`
or the event object definition. Message sent to backend agent 2026-04-08.
### [Journals] Journal Entry Config follow-ups
- [ ] **[Journals] Entry passcode secondary auth** — implement `passcode_hash` comparison.
**Frontend (after backend ships):**
- `src/lib/ae_events/ae_events__event.ts``search__event()`: add `contact_li_json_ext`
as an OR condition alongside `default_qry_str` when `qry_str` is present.
- `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte` fast-path IDB filter: parse
`contact_li_json` and include contact names/emails in the local text match check.
---
- [ ] **[IDAA / Events] Audit `default_qry_str` coverage in other event search pages.**
The backend was updated 2026-03-31 to expose `default_qry_str` in API responses.
Frontend fix applied to Recovery Meetings (`+page.svelte` + `properties_to_save`).
Check all other event search pages that use `db_events.event.filter()` or a secondary
post-API text filter — they may have the same mismatch (local searches `name`/`description`
only while server uses `default_qry_str`). Start with: any route under `/events/` or `/idaa/`
that has a full-text search input.
## 🧪 Testing & Optimization
- [x] **[package.json] Remove orphaned ShadCN/bits-ui packages.** `shadcn-svelte` and `bits-ui`
remain in `package.json` but have no usages — `src/lib/components/ui/` was removed 2026-03-27
(trashed to `~/tmp/gemini_trash/shadcn_components_ui_2026-03-27`). Removed from `package.json` and
`package-lock.json` on 2026-04-02.
- [ ] **[IDAA] IDB fast-path contact search** — parse `contact_li_json` in `search__event()`.
- [ ] **[IDAA] Optimize Recovery Meetings SQL VIEW and indexes.**
- [ ] **[IDAA / Events] Audit `default_qry_str` coverage** in all other event search pages.
- [ ] **[Launcher/VLC] Linux playback investigation** — fullscreen + pause-on-end flags.
### [IDAA] Jitsi config editor + live site fix
- [ ] **Fix live site (id=17) `jitsi_token_endpoint` pointing to dev-api:** DB has
`https://dev-api.oneskyit.com/api/jitsi_token` for both site 10 and site 17 (IDAA live).
Need to update site 17 in **production** to `https://api.oneskyit.com/api/jitsi_token`.
SQL: `UPDATE site SET cfg_json = JSON_SET(cfg_json, '$.jitsi_token_endpoint', 'https://api.oneskyit.com/api/jitsi_token') WHERE id = 17;`
---
- [ ] **Add IDAA Jitsi config editor UI** to the jitsi_reports page (administrator_access only),
alongside the existing Jitsi URL Builder section. Should allow editing key fields in
`site_cfg_json` without needing phpMyAdmin:
- `jitsi_token_endpoint` — the JWT signing endpoint (needs to point to prod)
- Jitsi domain default (currently hardcoded as `jitsi.dgrzone.com` fallback in the page)
- `novi_jitsi_mod_li` — list of Novi UUIDs who get moderator privileges
Read from `$ae_loc.site_cfg_json`, PATCH the site record via V3 CRUD
(`PATCH /v3/crud/site/{id}/`), reload `$ae_loc.site_cfg_json` on save so it takes
effect without re-login.
## ⚙️ DevOps & Backend
### [PWA] Service worker ignoring `chrome-extension://` requests
Browser console shows repeated errors:
```text
TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
```
The service worker's fetch/install handler is trying to cache requests with `chrome-extension://`
URLs (injected by browser extensions), which the Cache API rejects. Fix: filter out non-`http`/`https`
requests before attempting to cache. In the service worker fetch handler, add a guard:
```js
if (!event.request.url.startsWith('http')) return; // skip chrome-extension:// etc.
```
Locate in `static/service-worker.js` or the Vite PWA plugin config. Low severity — doesn't break
functionality, but pollutes the console and may cause unhandled promise rejections.
- [ ] **[Backend] `event_file` — add `cfg_json` column (post-CMSC)** — The per-file display
override currently uses a localStorage workaround (`$events_loc.launcher.file_display_overrides`)
because `event_file` has no JSON blob column. Proper fix: add `cfg_json` to the `event_file` DB
table, expose it through the FastAPI model, then migrate the frontend back to reading/writing the
backend field (restoring global/cross-device persistence). Frontend code is in
`launcher_file_cont.svelte` — search for `file_display_overrides`.
- [ ] **[Backend] Re-add `Access-Control-Allow-Private-Network: true` CORS header.**
- [x] **[DevOps] Service worker `skipWaiting` + `clients.claim`** — Root cause of "users see
old code / can't reproduce in dev testing": the SW sat in waiting state until all tabs closed.
IDAA members leave idaa.org open all day. Fixed 2026-06-03: both calls added to
`src/service-worker.js`. See mistake #16 in `BOOTSTRAP__AI_Agent_Quickstart.md`.
- [ ] **[DevOps] Nginx proxy buffer tuning** — Buffer settings copied from PHP guide; not
optimal for Node.js. `proxy_busy_buffers_size` technically exceeds safe limit. Re-examine
when enabling compression (now re-enabled) stabilizes.
- [ ] **[DevOps] Simplify Dockerfile env file selection** — Use plain `.env` instead of `BUILD_MODE`.
### [Badges] Remaining badge work before first live event
- **Badge print controls UX polish:** Scott has improvements in mind — TBD next session.
File: `ae_comp__badge_print_controls.svelte`.
---
### [Leads] Exhibitor Lead Scanning — IN PROGRESS (demo-ready prep)
Module is substantially built as a PWA (no Electron). Core flow works end-to-end.
Spec: `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` and `_detail.md`.
Full audit: `src/routes/events/[event_id]/(leads)/` and `src/lib/ae_events/ae_events__exhibit*.ts`.
**What's working:**
- Exhibit search/landing (`/leads/`) — SWR, local + API search, sort
- Exhibit detail page — 4-tab layout, sticky header with Add/List toggle, auto-refresh timer
- Tab 1 (Start): sign-in via shared passcode OR licensed user (email + passcode)
- Tab 2 (Add): QR scan (confirm mode — replaced rapid/qualify) + manual badge search; duplicate/re-enable detection on both
- Tab 3 (List): SWR lead list, licensee filter (All / My Leads), sort options, export button
- Tab 4 (Manage): admin tools, booth profile edit, passcode, license mgmt, custom questions config, app settings (refresh interval, clear IDB/localStorage, reload)
- Lead detail page: view/edit custom question responses, exhibitor notes (TipTap), priority/enable flags
- Export wired to V3 action endpoint `/v3/action/event_exhibit/{id}/tracking_export` (CSV/XLSX)
**Remaining before demo:**
- [x] **Export endpoint** — V3 action endpoint confirmed live on backend (2026-03-16). Returns 403 if
`leads_api_access` is not enabled on the exhibit — expected behavior. Export button now gated in
UI: only renders when `$lq__exhibit_obj?.leads_api_access === true`. Enable via:
`PATCH /v3/crud/event_exhibit/{id}` with `{ "leads_api_access": true }`.
- [x] **`allow_tracking` gate** — implemented (2026-03-16). QR scanner shows a warning card and
blocks the add. Manual search shows a ShieldOff "Opt-Out" badge per row and guards `add_as_lead`.
Opt-in model: `allow_tracking` must be explicitly `true` on the badge. Also added `allow_tracking`
and `agree_to_tc` to `ae_EventBadge` in `ae_types.ts`.
**Demo note:** ensure test badges have `allow_tracking = true` or no one can be added.
- [x] **Payment component**`ae_comp__exhibit_payment.svelte` fully implemented (2026-03-27).
Reads Stripe config from `$ae_loc.site_cfg_json` (`stripe_publishable_key`, `stripe_btn_1/3/6/10_license`).
License tier selector (1/3/6/10 users) with `{#key}` remount pattern for Stripe web component.
3 states: paid confirmation (priority=true), admin setup hint / "contact organizer" (no Stripe config),
payment form. `client_reference_id=exhibit_id`. TypeScript declaration in `app.d.ts`.
Stripe keys verified visible in `$ae_loc.site_cfg_json` on dev/demo site. Keys need validity check in Stripe dashboard.
- [x] **End-to-end smoke test (canceled by client)** — sign in with shared passcode, scan/search a badge, add a lead, view detail, add notes/responses, export CSV; canceled 2026-04-09.
- [x] **Install prompt** — PWA install nudge implemented (2026-03-16). `pwa_install.svelte.ts`
singleton captures `beforeinstallprompt` (Chrome/Android/desktop) and detects iOS Safari
for manual "Share → Add to Home Screen" instructions. Reusable `element_pwa_install_prompt.svelte`
placed on the Leads Start tab between the feature grid and sign-in. `pwa_install.init()` wired
into root `+layout.svelte`; dismiss persists 7 days via localStorage. svelte-check: 0 errors.
### [DevOps] Remaining deployment items
- [x] **Wire AE_APP_REPLICAS:** `docker-compose.yml` line 147 already has `scale: ${AE_APP_REPLICAS:-1}`. (verified 2026-03-11)
- [x] **Archive ae_env_node_app:** Archived as tar.gz under `~/OSIT_dev/backups/`; old history/docs moved to `~/OSIT_dev/for_reference_only/`. (2026-03-11)
- [x] **Build Optimization:** Current state finalized. Local Gitea instance stood up at `git.dgrzone.com` (Docker, home server) — future: migrate repos from Bitbucket, verify Backblaze/restic backups cover Gitea data. (2026-03-11)
- [x] **Remote deploy script:** `aether_container_env/deploy.sh` — SSH-triggered from workstation via `npm run deploy:remote:test/prod`. Handles git pull (ff-only) + docker build + restart. Tested and working on test env. (2026-03-25)
- [x] **`.env.default` cleanup:** Removed 16 dead variables, added missing `AE_NETWORK_NAME`/`CONTAINER_DOZZLE`/`AE_DOZZLE_PORT`, parameterized all container names (`CONTAINER_MARIADB`, `CONTAINER_PMA`, `CONTAINER_AE_OPS`) with `:-default` fallbacks in compose. ("Dozzle" = log viewer container.) (2026-03-26)
- [x] **Prod deploy:** Run `npm run deploy:remote:prod` (off-peak). Prerequisites: both repos pushed to Bitbucket ✓; verify `.env.prod` exists in `/srv/apps/prod_aether_app_sveltekit/` on Linode before running. (2026-03-30)
- [x] **Bitbucket → SSH migration:** Switched all three repos (`aether_app_sveltekit`, `aether_container_env`, `aether_api_fastapi`) to SSH remotes (`git@bitbucket.org`) on workstation. App passwords deprecated — SSH unaffected. (2026-03-27)
- [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from same branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but should establish proper branch separation (e.g. `main`/`master` for prod).
- [ ] **Tier 2 deploy (Gitea webhook):** Push-triggered deploys via Gitea webhook → listener on Linode → `deploy.sh`. Deferred until Gitea usage is more established.
### [General]
- [x] **Temp Cleanup:** `cleanup_tmp_files` wired in `launcher_background_sync.svelte`; called at launcher startup. Confirmed working. (2026-03-11)
- [x] **`window.print()` for badge print button:** Wired in `ae_comp__badge_print_controls.svelte` — increments count, fires `window.print()`, redirects to badge search. (done)
- **Input Field Audit:** Several input fields are missing `name`/`id` attributes or `data-testid`. Known examples: badge override fields in `ae_comp__badge_obj_view.svelte`; template name input in `ae_comp__badge_template_form.svelte`. Matters for: accessibility, autofill, label associations, and test targeting. (For tests, use `getByLabel()` rather than `input[value*=...]` which only checks the HTML attribute, not the Svelte-bound DOM property.)
## ✅ Completed (2026-03)
## ✅ Completed (archived)
See the full completed history in [documentation/TODO__Agents__ARCHIVE_2026-03.md](documentation/TODO__Agents__ARCHIVE_2026-03.md).
See the full completed history in:
[documentation/archive/TODO__Agents__ARCHIVE_2026-03.md](documentation/archive/TODO__Agents__ARCHIVE_2026-03.md)
[documentation/archive/TODO__Agents__ARCHIVE_2026-04.md](documentation/archive/TODO__Agents__ARCHIVE_2026-04.md)
[documentation/archive/TODO__Agents__ARCHIVE_2026-05.md](documentation/archive/TODO__Agents__ARCHIVE_2026-05.md)
[documentation/archive/TODO__Agents__ARCHIVE_2026-06.md](documentation/archive/TODO__Agents__ARCHIVE_2026-06.md)

View File

@@ -0,0 +1,227 @@
# Frontend Agent Task List
> Use this file to track steps for complex features or bug fixes.
> **Status:** Stable — ongoing development.
## 🚧 Upcoming High Priority
### [Stores] Svelte 4 → Svelte 5 State Migration (prerequisite for Phase 2c)
The app uses `svelte-persisted-store` (Svelte 4 store contract) for all core persisted state
(`ae_loc`, `idaa_loc`, `ae_api`, `ae_sess`, etc.). In Svelte 5 `$effect`, reading **any field**
of a Svelte 4 store subscribes to the **entire store** — coarse-grained reactivity. This is the
root cause of the IDAA Novi re-auth bug (2026-03-30): unrelated `$ae_loc` writes (e.g. iframe
height, SWR cfg reload) triggered the Novi verification effect repeatedly.
Migration target: replace `svelte-persisted-store` with Svelte 5 `$state`-based persistence
(e.g. `runed` `PersistedState`, or a lightweight custom wrapper). This gives fine-grained
reactivity — only effects that actually read a changed field re-run.
**Phased approach (do NOT do all at once):**
- [ ] **Phase A — Project plan + wrapper decision:** Write `PROJECT__Stores_Svelte5_Migration.md`.
Decide: `runed` library vs. custom `$state` + localStorage wrapper. Audit all store consumers.
Identify stores in priority order. Estimate blast radius per store.
- [ ] **Phase B — Core auth stores (highest impact, start here):**
- `ae_loc` (persisted) — auth flags, site cfg, UI state; ~471 consumer sites across 150+ files
- `idaa_loc` (persisted) — Novi auth, IDAA query prefs
These two cause the most reactive noise. Migrating them also unlocks Phase 2c (separate `ae_auth`
store) since the callsite sweep is now required anyway.
- [ ] **Phase C — Remaining persisted stores:**
- `ae_api` (persisted) — API config / JWT
- `ae_events_stores` persisted entries (badges, launcher, leads, pres_mgmt loc stores)
- [ ] **Phase D — Non-persisted writable stores:**
- `ae_sess`, `idaa_sess`, `slct`, `slct_trigger`, `ae_auth_error`, `ae_trig`, `ae_snip`, etc.
- Lower urgency (no localStorage churn), but fine-grained reactivity still beneficial.
- [ ] **Phase E — Phase 2c (unblocked after B):** Split `ae_loc` into `ae_auth` + `ae_app`
(see entry below — ~471 callsites, but sweep is cheap once already touching every consumer).
**Project plan doc needed:** Yes — scope is app-wide. Do NOT start Phase B without Phase A.
---
### [Stores] Refactor — Phase 2c (deferred)
Phases 1, 2a, 2b are complete (see ✅ Completed below). One phase remaining:
- [ ] **Phase 2c — Actual separate stores (`ae_auth`, `ae_app`):** Requires touching ~471
`$ae_loc.*` auth-field read sites across 150+ files. Deferred until a Svelte runes migration
of the store layer itself (touching every component anyway makes the callsite sweep cheap).
### [Backend] Join event_location_id onto event_presenter API view
The `event_presenter` object currently has `event_session_id` but not `event_location_id`.
When navigating from the Presenter View to the Launcher, the frontend has to do a secondary
session lookup to discover the location (magic redirect in launcher base `+page.svelte`).
Joining `event_session.event_location_id` into the presenter view/response would let the
frontend pass the location directly in the Launcher URL without the extra lookup.
- [x] Backend: added `event_location_id` (and `event_location_id_random`) to the `event_presenter` view or API response (2026-04-09)
- [x] Frontend: updated `ae_EventPresenter` type and `properties_to_save`; now pass as `events__launcher_id` in `presenter_page_menu.svelte` (2026-04-09)
### [TypeScript] svelte-check hidden errors — discovered 2026-03-27
**HOW WE FOUND THIS:** The `@lucide/svelte` 0.577.0 update (2026-03-10) dropped `class` from
`IconProps`. Fixing it required a `declare module '@lucide/svelte'` augmentation. That
augmentation was mistakenly placed in `app.d.ts`, which is a *script-context* declaration file
(no `export {}`). In that context, `declare module` is an **ambient replacement**, not a merge —
it wiped all icon exports from svelte-check's view, surfacing 1368 previously hidden errors.
Once moved to `src/lucide-augment.d.ts` (a proper module file with `export {}`), the masking
lifted and the real pre-existing errors became visible.
**Lesson:** A broken ambient declaration can silently hide unrelated errors. If svelte-check
suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a package's types.
**Current state (2026-03-31):** 32 errors, 0 warnings — all `ModalProps.children`.
- [ ] **[flowbite-svelte] `ModalProps.children` — 31 errors across 26 files.** The flowbite-svelte
`Modal` component API changed; `children` is no longer a direct prop (now Svelte snippet-based).
Affected files span journals, pres_mgmt, events/settings, and IDAA archives.
Run `npx svelte-check 2>&1 | grep ModalProps` to get the current list.
Fix pattern: replace `children` prop binding with Svelte snippet syntax per flowbite-svelte docs.
- [ ] **[IDAA] Make `contact_li_json_ext` searchable — Recovery Meeting contact search (2026-04-08)**
Members cannot search for meetings by contact name or email. `contact_li_json` data is not
included in `default_qry_str` and MariaDB cannot substring-search a JSON longtext directly.
The `event` table already has `contact_li_json_ext` (STORED GENERATED, indexed) to work around this.
**Backend (blocked on this first):** Add `contact_li_json_ext` to the searchable fields
whitelist for the `event` object type — likely a one-line change in `ae_obj_types_def.py`
or the event object definition. Message sent to backend agent 2026-04-08.
**Frontend (after backend ships):**
- `src/lib/ae_events/ae_events__event.ts``search__event()`: add `contact_li_json_ext`
as an OR condition alongside `default_qry_str` when `qry_str` is present.
- `src/routes/idaa/(idaa)/recovery_meetings/+page.svelte` fast-path IDB filter: parse
`contact_li_json` and include contact names/emails in the local text match check.
- [ ] **[IDAA / Events] Audit `default_qry_str` coverage in other event search pages.**
The backend was updated 2026-03-31 to expose `default_qry_str` in API responses.
Frontend fix applied to Recovery Meetings (`+page.svelte` + `properties_to_save`).
Check all other event search pages that use `db_events.event.filter()` or a secondary
post-API text filter — they may have the same mismatch (local searches `name`/`description`
only while server uses `default_qry_str`). Start with: any route under `/events/` or `/idaa/`
that has a full-text search input.
- [x] **[package.json] Remove orphaned ShadCN/bits-ui packages.** `shadcn-svelte` and `bits-ui`
remain in `package.json` but have no usages — `src/lib/components/ui/` was removed 2026-03-27
(trashed to `~/tmp/agents_trash/shadcn_components_ui_2026-03-27`). Removed from `package.json` and
`package-lock.json` on 2026-04-02.
### [IDAA] Jitsi config editor + live site fix
- [ ] **Fix live site (id=17) `jitsi_token_endpoint` pointing to dev-api:** DB has
`https://dev-api.oneskyit.com/api/jitsi_token` for both site 10 and site 17 (IDAA live).
Need to update site 17 in **production** to `https://api.oneskyit.com/api/jitsi_token`.
SQL: `UPDATE site SET cfg_json = JSON_SET(cfg_json, '$.jitsi_token_endpoint', 'https://api.oneskyit.com/api/jitsi_token') WHERE id = 17;`
- [ ] **Add IDAA Jitsi config editor UI** to the jitsi_reports page (administrator_access only),
alongside the existing Jitsi URL Builder section. Should allow editing key fields in
`site_cfg_json` without needing phpMyAdmin:
- `jitsi_token_endpoint` — the JWT signing endpoint (needs to point to prod)
- Jitsi domain default (currently hardcoded as `jitsi.dgrzone.com` fallback in the page)
- `novi_jitsi_mod_li` — list of Novi UUIDs who get moderator privileges
Read from `$ae_loc.site_cfg_json`, PATCH the site record via V3 CRUD
(`PATCH /v3/crud/site/{id}/`), reload `$ae_loc.site_cfg_json` on save so it takes
effect without re-login.
### [PWA] Service worker ignoring `chrome-extension://` requests
Browser console shows repeated errors:
```text
TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
```
The service worker's fetch/install handler is trying to cache requests with `chrome-extension://`
URLs (injected by browser extensions), which the Cache API rejects. Fix: filter out non-`http`/`https`
requests before attempting to cache. In the service worker fetch handler, add a guard:
```js
if (!event.request.url.startsWith('http')) return; // skip chrome-extension:// etc.
```
Locate in `static/service-worker.js` or the Vite PWA plugin config. Low severity — doesn't break
functionality, but pollutes the console and may cause unhandled promise rejections.
### [Badges] Remaining badge work before first live event
- **Badge print controls UX polish:** Scott has improvements in mind — TBD next session.
File: `ae_comp__badge_print_controls.svelte`.
### [CSS] Global placeholder text color — too dark in light mode
Placeholder text inherits full input text color in light mode (Tailwind CSS default), making
placeholders indistinguishable from filled-in values. Most visible in badge print controls
where placeholders show the actual badge value (e.g. "John Smith").
Workaround: scoped `::placeholder` rule added to `ae_comp__badge_print_controls.svelte`
(gray-400 light / gray-500 dark) — `commit 7733ef8`.
**Long-term fix:** Add a global rule to the main CSS (e.g. `src/app.css` or a theme file):
```css
::placeholder {
color: #9ca3af; /* gray-400 */
opacity: 1; /* overrides Firefox's 0.54 default */
}
.dark ::placeholder {
color: #6b7280; /* gray-500 */
}
```
Once the global rule is in place, remove the scoped workaround from the badge controls.
### [Leads] Exhibitor Lead Scanning — IN PROGRESS (demo-ready prep)
Module is substantially built as a PWA (no Electron). Core flow works end-to-end.
Spec: `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` and `_detail.md`.
Full audit: `src/routes/events/[event_id]/(leads)/` and `src/lib/ae_events/ae_events__exhibit*.ts`.
**What's working:**
- Exhibit search/landing (`/leads/`) — SWR, local + API search, sort
- Exhibit detail page — 4-tab layout, sticky header with Add/List toggle, auto-refresh timer
- Tab 1 (Start): sign-in via shared passcode OR licensed user (email + passcode)
- Tab 2 (Add): QR scan (confirm mode — replaced rapid/qualify) + manual badge search; duplicate/re-enable detection on both
- Tab 3 (List): SWR lead list, licensee filter (All / My Leads), sort options, export button
- Tab 4 (Manage): admin tools, booth profile edit, passcode, license mgmt, custom questions config, app settings (refresh interval, clear IDB/localStorage, reload)
- Lead detail page: view/edit custom question responses, exhibitor notes (TipTap), priority/enable flags
- Export wired to V3 action endpoint `/v3/action/event_exhibit/{id}/tracking_export` (CSV/XLSX)
**Remaining before demo:**
- [x] **Export endpoint** — V3 action endpoint confirmed live on backend (2026-03-16). Returns 403 if
`leads_api_access` is not enabled on the exhibit — expected behavior. Export button now gated in
UI: only renders when `$lq__exhibit_obj?.leads_api_access === true`. Enable via:
`PATCH /v3/crud/event_exhibit/{id}` with `{ "leads_api_access": true }`.
- [x] **`allow_tracking` gate** — implemented (2026-03-16). QR scanner shows a warning card and
blocks the add. Manual search shows a ShieldOff "Opt-Out" badge per row and guards `add_as_lead`.
Opt-in model: `allow_tracking` must be explicitly `true` on the badge. Also added `allow_tracking`
and `agree_to_tc` to `ae_EventBadge` in `ae_types.ts`.
**Demo note:** ensure test badges have `allow_tracking = true` or no one can be added.
- [x] **Payment component**`ae_comp__exhibit_payment.svelte` fully implemented (2026-03-27).
Reads Stripe config from `$ae_loc.site_cfg_json` (`stripe_publishable_key`, `stripe_btn_1/3/6/10_license`).
License tier selector (1/3/6/10 users) with `{#key}` remount pattern for Stripe web component.
3 states: paid confirmation (priority=true), admin setup hint / "contact organizer" (no Stripe config),
payment form. `client_reference_id=exhibit_id`. TypeScript declaration in `app.d.ts`.
Stripe keys verified visible in `$ae_loc.site_cfg_json` on dev/demo site. Keys need validity check in Stripe dashboard.
- [x] **End-to-end smoke test (canceled by client)** — sign in with shared passcode, scan/search a badge, add a lead, view detail, add notes/responses, export CSV; canceled 2026-04-09.
- [x] **Install prompt** — PWA install nudge implemented (2026-03-16). `pwa_install.svelte.ts`
singleton captures `beforeinstallprompt` (Chrome/Android/desktop) and detects iOS Safari
for manual "Share → Add to Home Screen" instructions. Reusable `element_pwa_install_prompt.svelte`
placed on the Leads Start tab between the feature grid and sign-in. `pwa_install.init()` wired
into root `+layout.svelte`; dismiss persists 7 days via localStorage. svelte-check: 0 errors.
### [DevOps] Remaining deployment items
- [x] **Wire AE_APP_REPLICAS:** `docker-compose.yml` line 147 already has `scale: ${AE_APP_REPLICAS:-1}`. (verified 2026-03-11)
- [x] **Archive ae_env_node_app:** Archived as tar.gz under `~/OSIT_dev/backups/`; old history/docs moved to `~/OSIT_dev/for_reference_only/`. (2026-03-11)
- [x] **Build Optimization:** Current state finalized. Local Gitea instance stood up at `git.dgrzone.com` (Docker, home server) — future: migrate repos from Bitbucket, verify Backblaze/restic backups cover Gitea data. (2026-03-11)
- [x] **Remote deploy script:** `aether_container_env/deploy.sh` — SSH-triggered from workstation via `npm run deploy:remote:test/prod`. Handles git pull (ff-only) + docker build + restart. Tested and working on test env. (2026-03-25)
- [x] **`.env.default` cleanup:** Removed 16 dead variables, added missing `AE_NETWORK_NAME`/`CONTAINER_DOZZLE`/`AE_DOZZLE_PORT`, parameterized all container names (`CONTAINER_MARIADB`, `CONTAINER_PMA`, `CONTAINER_AE_OPS`) with `:-default` fallbacks in compose. ("Dozzle" = log viewer container.) (2026-03-26)
- [x] **Prod deploy:** Run `npm run deploy:remote:prod` (off-peak). Prerequisites: both repos pushed to Bitbucket ✓; verify `.env.prod` exists in `/srv/apps/prod_aether_app_sveltekit/` on Linode before running. (2026-03-30)
- [x] **Bitbucket → SSH migration:** Switched all three repos (`aether_app_sveltekit`, `aether_container_env`, `aether_api_fastapi`) to SSH remotes (`git@bitbucket.org`) on workstation. App passwords deprecated — SSH unaffected. (2026-03-27)
- [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from same branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but should establish proper branch separation (e.g. `main`/`master` for prod).
- [ ] **Tier 2 deploy (Gitea webhook):** Push-triggered deploys via Gitea webhook → listener on Linode → `deploy.sh`. Deferred until Gitea usage is more established.
### [General]
- [x] **Temp Cleanup:** `cleanup_tmp_files` wired in `launcher_background_sync.svelte`; called at launcher startup. Confirmed working. (2026-03-11)
- [x] **`window.print()` for badge print button:** Wired in `ae_comp__badge_print_controls.svelte` — increments count, fires `window.print()`, redirects to badge search. (done)
- **Input Field Audit:** Several input fields are missing `name`/`id` attributes or `data-testid`. Known examples: badge override fields in `ae_comp__badge_obj_view.svelte`; template name input in `ae_comp__badge_template_form.svelte`. Matters for: accessibility, autofill, label associations, and test targeting. (For tests, use `getByLabel()` rather than `input[value*=...]` which only checks the HTML attribute, not the Svelte-bound DOM property.)
## ✅ Completed (2026-03)
## ✅ Completed (archived)
See the full completed history in [documentation/TODO__Agents__ARCHIVE_2026-03.md](documentation/TODO__Agents__ARCHIVE_2026-03.md).

View File

@@ -0,0 +1,54 @@
# Frontend Agent Task List (Archived May 2026)
## ✅ Completed (2026-05)
### [API] GET/POST retry hardening — differentiate timeout aborts vs intentional aborts
**Status:** ✅ Completed (2026-05-21)
- GET/POST now explicitly distinguish abort class in helper code.
- Timeout-triggered aborts are retryable via existing retry loop; intentional aborts fail fast.
- Backoff behavior retained (`2s -> 4s -> 6s -> 8s`).
- Validation done via Playwright tests.
### [API] PATCH/DELETE retry hardening — parity with GET/POST
**Status:** ✅ Completed (2026-05-21)
- PATCH and DELETE now implement the same retry-classification model used in GET/POST.
- Added explicit fail-fast for 400/401/403/422.
- DELETE now triggers the session-expired banner on 401/403.
### [Testing] V3 API performance probe (basic stress rounds)
**Status:** ✅ Completed baseline harness (2026-05-21)
- Implemented a gated Playwright probe for quick repeated list-query timing against live V3 endpoints.
- Writes reports to `tests/results/`.
### [IDAA] Random "Access Denied" — Root Cause Review & Fixes
**Status:** ✅ Resolved (2026-05-19)
- Server-side Novi verification migrated to V3 action endpoint.
- Extended Novi TTL to 12 hours.
- Hardened retry and timeout logic in `+layout.svelte`.
### [IDAA] Server-side Novi verification — 503 not auto-retried
**Status:** ✅ Fixed (2026-05-20)
### [IDAA] Jitsi Reports filters
**Status:** ✅ Finished (2026-05-06)
- Added Novi UUID exclusion plus meeting-name whitelist filtering.
### [PWA] Service worker ignoring `chrome-extension://` requests
**Status:** ✅ Fixed (2026-05-14)
- Added guard to filter out non-http/https requests before Attempting to cache.
### [Electron/Launcher] Display mirroring auto-detection
**Status:** ✅ Completed (2026-05-20)
- `native:set-display-layout` now auto-detects displays via `displayplacer list`.
### [Launcher] Force Sync Location
**Status:** ✅ Completed (2026-05-21)
- Implemented manual trigger and background engine logic to pre-cache all location files.
### [Launcher] Chronological Download Priority
**Status:** ✅ Completed (2026-05-21)
- Refactored download queue to prioritize Event Assets > Early Sessions > Presentation Order > Created Date.
### [Launcher] Error handling + fallback
**Status:** ✅ Completed (2026-05-14)
- Post-script failure surfaces 'fallback' status; `open_cmd` failure falls back to OS default.

View File

@@ -0,0 +1,139 @@
# Frontend Agent Task List
> Use this file to track steps for complex features or bug fixes.
> **Status:** Stable — ongoing development.
## 🔴 CMSC Charlotte — May 27 (Presentation Management)
**Drive down:** May 25 | **Setup:** May 26 morning | **Show:** May 27+
- [x] **[Launcher] Composable open flow** — `handle_open_file()` uses `copy_from_cache_to_temp` +
`run_osascript` / `run_cmd` directly with per-step error handling. Complete.
- [x] **[Launcher] Slide control scripts in Svelte config** — AppleScript post_scripts live in
`ae_launcher__default_launch_profiles.ts`. VLC focus-stealing fix applied. Complete.
- [x] **[Launcher] Kill Apps button** — "Kill Apps" button added to Native OS config (System
Actions, edit mode only). Kills PowerPoint, Keynote, Adobe Acrobat Reader DC, VLC, soffice.
List overridable via `event_device.other_json.launcher.kill_process_li`. Auto-cleanup on file
open (deferred — manual button sufficient for CMSC).
- [x] **[Launcher] Hidden/deleted files still visible in Presenter file list** — Fixed by
API-to-Dexie stale-record pruning plus Launcher background refresh loops for file lists.
`ae_events__event_file.ts` now prunes stale records after refresh, and
`launcher_background_sync.svelte` refreshes/prunes selected session and presenter file lists.
(`fix(launcher): refresh file lists periodically to prune deleted/hidden files`, 2026-05)
- [ ] **[Launcher/Electron] Wallpaper stops applying after several changes (post-CMSC)** —
Append timestamp/random suffix to temp filename so macOS always sees a new path.
- [ ] **[Launcher/Electron] Wallpaper drift after display hotplug (post-CMSC)** —
Add resilient reconciliation loop or event-driven reapply on topology change.
---
## 🔴 Axonius DC — June 9 (Badge Printing)
**Setup/Registration:** June 8 | **Show:** June 9
- [ ] **[Badges] Epson C3500 fanfold badge layout** — Create/configure a fanfold badge layout
compatible with the Epson C3500 continuous stock format.
---
## 🚧 V3 CRUD Migration (Surgical Cleanup)
Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy wrappers.
- [x] **[Badges] Presenter Agreement Form** — migrated to `update_ae_obj` (2026-05-21)
- [x] **[Core] Site Domain Bootstrap Refactor** — Bootstrap path is already on V3 in
`ae_core__site.ts` via `lookup_site_domain()` using `api.search_ae_obj` with FQDN filter
(used by `src/routes/+layout.ts`).
Follow-up cleanup complete: retired legacy helper `core__site_domain.ts`. (2026-06-02)
- [ ] **[Core] Legacy Utility Helpers** — Refactor `ae_core_functions.ts` to use V3 helpers.
- [ ] **[Cleanup] Delete Legacy Wrappers** — Once all callsites are migrated, remove
`src/lib/ae_api/api_get__crud_obj_id.ts` and the legacy exports from `api.ts`.
---
## 🚧 High Priority Workstreams
### [Stores] Svelte 4 → Svelte 5 State Migration
The app uses `svelte-persisted-store` (coarse reactivity). Migration target: replace with Svelte 5
`$state`-based persistence for fine-grained updates.
- [ ] **Phase A — Project plan + wrapper decision:** Write `PROJECT__Stores_Svelte5_Migration.md`.
- [ ] **Phase B — Core auth stores (highest impact):** `ae_loc`, `idaa_loc`.
- [ ] **Phase C — Remaining persisted stores:** `ae_api`, `ae_events_stores`.
- [ ] **Phase D — Non-persisted writable stores:** `ae_sess`, `slct`, `ae_snip`, etc.
### [IDB Sort] `build_tmp_sort` rollout
Shared utility in `src/lib/ae_core/core__idb_sort.ts` — fixes priority direction (inverted,
true→'0' sorts first ASC) and zero-pads sort field (8 chars). No `.reverse()` needed.
Sort chain: `group → priority DESC → sort ASC → [module-specific fields] → name`.
**⚠️ Never use `.reverse()` on a `tmp_sort_*`-sorted list — inverted priority makes it wrong.**
Documented in `GUIDE__SvelteKit2_Svelte5_DexieJS.md` (IDB Sort section).
- [x] `ae_events__event_presentation` — group + priority + sort + start_datetime + code + name
- [x] `ae_journals__journal` + `ae_journals__journal_entry` — group + priority + sort + name + updated_on
- [ ] `ae_events__event_session` — roll out when sort behavior is reviewed
- [ ] `ae_events__event_presenter` — roll out when sort behavior is reviewed
- [ ] `ae_events__event_location` — roll out when sort behavior is reviewed
- [x] `ae_posts__post` + `ae_posts__post_comment` — migrated to `build_tmp_sort` with 8-char padding; BB comment list consumer updated for ASC tmp_sort ordering. (2026-06-02)
- [ ] `ae_core__person` + `ae_core__account` — roll out when sort behavior is reviewed
### [Stores] IDB Content Version System
- [x] Write `check_and_clear_idb_tables()` helper.
- [x] Wire helper into `db_journals.ts` and IDAA layout.
- [ ] Roll out to `db_events.ts` (module-wide: session, presenter, badge, etc.).
- [ ] Roll out to `db_core.ts` (site_domain, person, user).
### [TypeScript] svelte-check hidden errors
- [x] **[flowbite-svelte] `ModalProps.children` — 31 errors across 26 files.**
Verified no remaining `children={...}` bindings on `<Modal>` and `npx svelte-check` is clean. (2026-06-02)
### [Journals] Journal Entry Config follow-ups
- [ ] **[Journals] Entry passcode secondary auth** — implement `passcode_hash` comparison.
- [x] **[Journals] Summary AI shortcut** — added Quick Actions button in entry config modal and wired it to close modal + scroll to AI tools panel in entry edit view. (2026-06-02)
### [Cleanup] Migrate remaining `lucide-svelte` imports to `@lucide/svelte`
- [x] **[Cleanup] Migrate remaining `lucide-svelte` imports to `@lucide/svelte`**
Migrated all 5 listed files to `@lucide/svelte` and uninstalled `lucide-svelte` from dependencies. (2026-06-02)
---
### [Pres Mgmt] Sessions hide/show toggle
- [x] **[Pres Mgmt] Hidden sessions blink on initial load** — SCENARIO 2 fallback in
`pres_mgmt/+page.svelte` now captures `qry_hidden` as a `$derived.by` dependency and
applies the filter in the fallback path. No blink on page load. (2026-05-28)
- [x] **[Pres Mgmt] API call uses live store instead of snapshot** — changed
`pres_mgmt_loc.current.qry_hidden``params.qry_hidden` in `handle_search_refresh`
API call to be consistent with fast path snapshot. (2026-05-28)
- **Note:** `hide_event_launcher` is still active — used in `menu_session_list.svelte`
(Launcher) to CSS-hide sessions from the list. Button to toggle it is in
`session_page_menu.svelte`. Not used in Pres Mgmt (intentional — Pres Mgmt always shows all).
- **Note:** Non-trusted users always have `!item.hide` applied at the component level
in `ae_comp__event_session_obj_li.svelte` regardless of `qry_hidden`. Toggle is
trusted-access-only in practice; direct session links still work for non-trusted users.
---
## 🧪 Testing & Optimization
- [ ] **[IDAA] IDB fast-path contact search** — parse `contact_li_json` in `search__event()`.
- [ ] **[IDAA] Optimize Recovery Meetings SQL VIEW and indexes.**
- [ ] **[IDAA / Events] Audit `default_qry_str` coverage** in all other event search pages.
- [ ] **[Launcher/VLC] Linux playback investigation** — fullscreen + pause-on-end flags.
---
## ⚙️ DevOps & Backend
- [ ] **[Backend] `event_file` — add `cfg_json` column (post-CMSC)** — The per-file display
override currently uses a localStorage workaround (`$events_loc.launcher.file_display_overrides`)
because `event_file` has no JSON blob column. Proper fix: add `cfg_json` to the `event_file` DB
table, expose it through the FastAPI model, then migrate the frontend back to reading/writing the
backend field (restoring global/cross-device persistence). Frontend code is in
`launcher_file_cont.svelte` — search for `file_display_overrides`.
- [ ] **[Backend] Re-add `Access-Control-Allow-Private-Network: true` CORS header.**
- [ ] **[DevOps] Nginx caching** — Investigate `index.html` cache-pickup issues.
- [ ] **[DevOps] Simplify Dockerfile env file selection** — Use plain `.env` instead of `BUILD_MODE`.
---
## ✅ Completed (archived)
See the full completed history in:
[documentation/archive/TODO__Agents__ARCHIVE_2026-03.md](documentation/archive/TODO__Agents__ARCHIVE_2026-03.md)
[documentation/archive/TODO__Agents__ARCHIVE_2026-04.md](documentation/archive/TODO__Agents__ARCHIVE_2026-04.md)
[documentation/archive/TODO__Agents__ARCHIVE_2026-05.md](documentation/archive/TODO__Agents__ARCHIVE_2026-05.md)

273
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "osit-aether-app-svelte",
"version": "3.00.10",
"version": "3.00.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "osit-aether-app-svelte",
"version": "3.00.10",
"version": "3.00.20",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.0",
@@ -26,12 +26,10 @@
"@lucide/svelte": "^0.*.0",
"@popperjs/core": "^2.11.0",
"@tailwindcss/vite": "^4.1.10",
"axios": "^1.7.0",
"dayjs": "^1.11.10",
"dexie": "^4.0.0",
"flowbite-svelte": "^1.28.1",
"html5-qrcode": "^2.3.8",
"lucide-svelte": "^0.*.0",
"marked": "^17.0.0",
"openai": "^6.10.0",
"prettier-plugin-tailwindcss": "^0.7.2",
@@ -3929,23 +3927,6 @@
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3976,19 +3957,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4101,18 +4069,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -4240,15 +4196,6 @@
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -4299,20 +4246,6 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -4332,24 +4265,6 @@
"node": ">=10.13.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -4357,33 +4272,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -4935,42 +4823,6 @@
"mini-svg-data-uri": "^1.4.3"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -5003,43 +4855,6 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5065,18 +4880,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5092,33 +4895,6 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -5654,15 +5430,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lucide-svelte": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -5693,36 +5460,6 @@
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -6317,12 +6054,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "osit-aether-app-svelte",
"version": "3.00.10",
"version": "3.00.30",
"description": "One Sky IT's Aether App created with Svelte, SvelteKit, Tailwind CSS, Lucide, Font Awesome, and Skeleton UI. -Scott Idem",
"homepage": "https://oneskyit.com/",
"private": true,
@@ -19,8 +19,6 @@
"test:integration": "playwright test",
"test:unit": "vitest",
"build:docker:dev": "docker compose -f ../aether_container_env/docker-compose.yml build ae_app && docker compose -f ../aether_container_env/docker-compose.yml up -d ae_app",
"build:docker:test": "docker compose -f ../aether_container_env/docker-compose.yml build --build-arg BUILD_MODE=test ae_app && docker compose -f ../aether_container_env/docker-compose.yml up -d ae_app",
"build:docker:prod": "docker compose -f ../aether_container_env/docker-compose.yml build --build-arg BUILD_MODE=prod ae_app && docker compose -f ../aether_container_env/docker-compose.yml up -d --remove-orphans ae_app",
"compose:down": "docker compose -f ../aether_container_env/docker-compose.yml --profile database down",
"deploy:remote:test": "ssh linode.oneskyit.com 'bash /srv/env/test_aether/deploy.sh test'",
"deploy:remote:prod": "ssh linode.oneskyit.com 'bash /srv/env/prod_aether/deploy.sh prod'"
@@ -107,12 +105,10 @@
"@lucide/svelte": "^0.*.0",
"@popperjs/core": "^2.11.0",
"@tailwindcss/vite": "^4.1.10",
"axios": "^1.7.0",
"dayjs": "^1.11.10",
"dexie": "^4.0.0",
"flowbite-svelte": "^1.28.1",
"html5-qrcode": "^2.3.8",
"lucide-svelte": "^0.*.0",
"marked": "^17.0.0",
"openai": "^6.10.0",
"prettier-plugin-tailwindcss": "^0.7.2",

View File

@@ -20,7 +20,7 @@ FA_TO_LUCIDE = {
'fa-times': 'X',
'fa-exclamation-triangle': 'TriangleAlert',
'fa-check': 'Check',
'fa-check-circle': 'CheckCircle',
'fa-check-circle': 'CircleCheck',
'fa-plus': 'Plus',
'fa-minus': 'Minus',
'fa-save': 'Save',

View File

@@ -163,9 +163,16 @@ html.light {
background-color: rgb(55 65 81); /* gray-700 */
border-color: rgb(75 85 99); /* gray-600 */
}
.input::placeholder,
.textarea::placeholder {
font-style: italic;
opacity: 0.6;
}
.dark .input::placeholder,
.dark .textarea::placeholder {
color: rgb(156 163 175); /* gray-400 — legible at reduced opacity */
color: rgb(156 163 175); /* gray-400 */
font-style: italic;
opacity: 0.8; /* gray-400 is already dim; subtle additional fade */
}
/* Option elements in dark selects — forces browser native dark chrome */
.dark .select option {
@@ -233,12 +240,14 @@ html.trusted_access #appShell {
font-display: swap;
} */
/* modern theme */
@font-face {
/* modern theme — @font-face commented out 2026-05-19: Quicksand is declared but no
CSS rule applies font-family:'Quicksand' to any element, so the browser never
fetches this file. Re-enable if a theme or component starts using it. */
/* @font-face {
font-family: 'Quicksand';
src: url('/fonts/Quicksand.ttf');
font-display: swap;
}
} */
/* :root [data-theme='modern'] { */
/* --theme-rounded-base: 20px;
@@ -916,8 +925,14 @@ img.qr_code:focus {
/* BEGIN: Overrides and fixes specific to Novi and IDAA */
.iframe .novi_btn {
border-radius: 60px;
/* border-color: hsla(0, 0%, 50%, .5); */
/* border-color: hsla(0, 0%, 0%, .15); */
/* Bootstrap v3 (.btn) sets border:1px solid transparent and wins over
Skeleton/Tailwind preset-outlined classes when loaded last. Use box-shadow
instead — Bootstrap does not set box-shadow on .btn so it cannot strip it. */
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.iframe .novi_btn:hover {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.04);
}
.iframe .novi_m0 {

View File

@@ -3,11 +3,58 @@
<head>
<meta charset="utf-8" />
<script>
(() => {
const hostname = window.location.hostname;
const is_local_dev =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname.endsWith('.localhost');
if (!is_local_dev || !('serviceWorker' in navigator)) return;
// Prevent the app bootstrap from re-registering a worker on localhost.
// The browser can otherwise keep routing fetches through a stale SW
// during iterative iframe testing, which is exactly the noise we want to avoid.
try {
Object.defineProperty(navigator.serviceWorker, 'register', {
configurable: true,
value: async () => ({})
});
} catch {
// If the property is not writable in this browser, the unregister
// pass below still removes any existing worker registration.
}
// Local iframe testing should not keep an older worker alive, because
// Chromium can continue to route fetches through the stale worker until
// it is explicitly unregistered.
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister().catch(() => {});
}
}).catch(() => {});
// Clear any stale runtime caches as well; local testing should always
// rebuild from the current source rather than reusing old worker output.
if ('caches' in window) {
caches.keys().then((cache_keys) => {
for (const cache_key of cache_keys) {
caches.delete(cache_key).catch(() => {});
}
}).catch(() => {});
}
})();
</script>
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Google Fonts: commented out 2026-05-19 — no theme or component applies these families;
all themes use system-ui/sans-serif. Re-enable if a theme is added that references them.
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
@@ -19,14 +66,43 @@
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet" />
-->
<!-- <link href="app.css" rel="stylesheet"> -->
<!-- Pre-JS loading indicator. Removed by root +layout.svelte onMount once Svelte
bootstraps and the existing is_hydrating overlay takes over. Pointer-events:none
so it never blocks interaction if something goes wrong with the remove call. -->
<style>
#ae_loader {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
}
#ae_loader::after {
content: '';
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid rgba(120, 120, 120, 0.15);
border-top-color: rgba(120, 120, 120, 0.45);
animation: ae_loader_spin 0.75s linear infinite;
}
@keyframes ae_loader_spin {
to { transform: rotate(360deg); }
}
</style>
%sveltekit.head%
</head>
<!-- h-full w-full overflow-auto -->
<!-- overflow-x-scroll -->
<body data-sveltekit-preload-data="hover" class="h-full w-full">
<div id="ae_loader" aria-hidden="true"></div>
<div style="display: contents" class="">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import { browser } from '$app/environment';
import { ae_auth_error } from '$lib/stores/ae_stores';
import type { key_val } from '$lib/stores/ae_stores';
/**
@@ -11,7 +13,7 @@ export const delete_object = async function delete_object({
headers = {},
params = {},
data = {},
timeout = 60000,
timeout = 20000,
return_meta = false,
log_lvl = 0,
retry_count = 5
@@ -97,9 +99,15 @@ export const delete_object = async function delete_object({
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
// Keep timeout handle at attempt scope so catch can always clear it.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
// AbortError alone is ambiguous. Track helper-timeout aborts so
// caller/navigation aborts can still fail fast with no retry.
let did_timeout_abort = false;
timeoutId = setTimeout(() => {
did_timeout_abort = true;
console.error(
`API DELETE request timed out after ${timeout}ms.`
);
@@ -120,12 +128,48 @@ export const delete_object = async function delete_object({
url.toString(),
fetchOptions
).catch(function (error: any) {
if (
error?.name === 'AbortError' ||
error?.name === 'TypeError' ||
error?.message?.includes('aborted')
) {
if (log_lvl > 1) {
console.log(
'API DELETE: Request aborted or browser-terminated.',
error
);
}
return error;
}
console.log(
'API DELETE Object *fetch* request was aborted or failed in an unexpected way.',
error
);
return error;
});
clearTimeout(timeoutId);
if (timeoutId) clearTimeout(timeoutId);
// Error object was returned from fetch catch block; decide retry class.
if (
response instanceof Error ||
(response &&
(response.name === 'AbortError' ||
response.name === 'TypeError'))
) {
if (response.name === 'AbortError') {
if (did_timeout_abort) {
throw new Error(
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
);
}
return false;
}
throw new Error(
`Network error (attempt ${attempt}): ${response.message}`
);
}
if (!response) {
throw new Error(
@@ -151,7 +195,24 @@ export const delete_object = async function delete_object({
errorBody
);
if (response.status >= 400 && response.status < 404) {
// Fail fast on client/auth/validation failures.
if (
response.status === 400 ||
response.status === 401 ||
response.status === 403 ||
response.status === 422
) {
if (response.status === 401 || response.status === 403) {
console.warn(
`AUTH DIAGNOSTICS (DELETE): Headers sent for ${endpoint}:`,
{
has_api_key: !!headers_cleaned['x-aether-api-key'],
has_account_id: !!headers_cleaned['x-account-id']
}
);
// Signal the root layout to show the session-expired banner.
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
}
return false;
}
@@ -174,6 +235,8 @@ export const delete_object = async function delete_object({
? json.data
: json;
} catch (error) {
// Ensure per-attempt timeout is always cleared on failure.
if (timeoutId) clearTimeout(timeoutId);
console.error(`API DELETE error on attempt ${attempt}:`, error);
if (attempt === retry_count) {
@@ -181,9 +244,12 @@ export const delete_object = async function delete_object({
return false;
}
if (log_lvl) {
console.log(`Retrying... (${attempt}/${retry_count})`);
}
// Backoff before retrying. Caps at 8s to match GET/POST/PATCH policy.
const delay_ms = Math.min(2000 * attempt, 8000);
console.log(
`API DELETE: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`
);
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};

View File

@@ -12,7 +12,9 @@ interface GetDataStoreV3Params {
/**
* Get a Data Store object by its human-friendly code (V3)
* Uses hierarchical fallback logic (Specific -> Account -> Global)
* Uses hierarchical fallback logic (Specific -> Account -> Global).
* TEMPORARY: the global fallback is a stopgap until the backend can
* serve account-scoped defaults via JWT-backed access only.
* Path: GET /v3/data_store/code/{code}
*/
export async function get_data_store({
@@ -36,8 +38,10 @@ export async function get_data_store({
const headers: key_val = {};
if (no_account_id) {
// This token allows bypassing the mandatory account_id requirement for global defaults
headers['x-no-account-id-token'] = 'Nothing to See Here';
// TEMPORARY: keep this narrow global-default escape hatch until the
// backend can answer the data_store request with account-scoped JWT
// access only.
headers['x-no-account-id'] = 'Nothing to See Here';
}
return await get_object({

View File

@@ -14,7 +14,7 @@ export const get_object = async function get_object({
headers = {},
params = {},
data = {},
timeout = 90000,
timeout = 20000,
return_meta = false,
return_blob = false,
filename = '',
@@ -73,9 +73,6 @@ export const get_object = async function get_object({
url.searchParams.append(key, params[key])
);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Clean and merge headers without mutating the original api_cfg
const headers_cleaned: key_val = {};
const merged_headers = { ...api_cfg['headers'], ...headers };
@@ -108,7 +105,6 @@ export const get_object = async function get_object({
const is_valid_bypass =
bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
if (is_valid_bypass) {
@@ -170,10 +166,11 @@ export const get_object = async function get_object({
console.log('Final cleaned headers:', headers_cleaned);
}
// signal is injected per-attempt inside the retry loop so each retry gets
// a fresh AbortController with its own independent timeout.
const fetchOptions: RequestInit = {
method: 'GET',
headers: headers_cleaned,
signal: controller.signal,
// Be explicit about CORS behavior and redirect handling to avoid
// environment-dependent defaults that can cause opaque failures.
mode: 'cors',
@@ -204,10 +201,24 @@ export const get_object = async function get_object({
return false;
}
// Fresh AbortController per attempt — ensures each retry has its own
// independent timeout. Sharing a single controller across retries leaves
// retries unprotected once the first attempt's clearTimeout() runs.
const controller = new AbortController();
// Track whether THIS helper's timeout fired. AbortError alone is ambiguous:
// it can mean timeout OR intentional caller abort (navigation/unmount).
// We only retry timeout-aborts; intentional aborts should fail fast.
let did_timeout_abort = false;
const timeoutId = setTimeout(() => {
did_timeout_abort = true;
console.warn(`API GET: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
controller.abort();
}, timeout);
try {
const response = await fetch_method(
url.toString(),
fetchOptions
{ ...fetchOptions, signal: controller.signal }
).catch(function (error: any) {
// SILENCE NOISE: Aborted requests (common in SWR/Background loads) shouldn't spam logs
if (
@@ -232,21 +243,36 @@ export const get_object = async function get_object({
});
clearTimeout(timeoutId);
// Check if we should stop due to abort or network failure
// Check if we should stop due to abort or network failure.
if (
response instanceof Error ||
(response &&
(response.name === 'TypeError' ||
response.name === 'AbortError'))
) {
// If it was an explicit abort, definitely stop
if (response.name === 'AbortError') return false;
// AbortError can be either timeout or intentional abort.
// Retry only helper-owned timeout aborts; fail fast on caller abort.
if (response.name === 'AbortError') {
if (did_timeout_abort) {
throw new Error(
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
);
}
return false;
}
if (log_lvl > 1)
console.log(
'API GET Object: Detected NetworkError or TypeError. Failing fast.'
);
return false;
// TypeError = transient network failure (ERR_NETWORK_CHANGED,
// ERR_NETWORK_IO_SUSPENDED, hotel/conference WiFi blip, etc.).
// IMPORTANT: throw here so the retry loop's catch block handles it with
// backoff. Returning false would bypass retries entirely.
//
// WHY THIS WAS BROKEN: The Jan 2026 "offline-first fast-paths" commit
// (a10accfaa) changed .catch() to return the error as a value instead of
// not returning (undefined). The undefined path fell through to the
// `if (!response)` throw which DID retry. The explicit `return error` +
// this `return false` block silently killed the retry for the most common
// failure mode on conference/hotel WiFi.
throw new Error(`Network error (attempt ${attempt}): ${response.message}`);
}
if (!response) {
@@ -439,6 +465,8 @@ export const get_object = async function get_object({
}
}
} catch (error) {
// Ensure the per-attempt timeout timer is always cancelled on failure.
clearTimeout(timeoutId);
console.log(
`API GET object request *fetch* error on attempt ${attempt}:`,
error
@@ -449,10 +477,13 @@ export const get_object = async function get_object({
return false;
}
// Log retry information
if (log_lvl) {
console.log(`Retrying... (${attempt}/${retry_count})`);
}
// Backoff before retrying. Without a delay, rapid retries on a flaky
// connection accomplish nothing and add noise. Caps at 8s so later
// attempts don't wait excessively. Gives the network time to recover
// (ERR_NETWORK_CHANGED is typically a sub-second WiFi roam event).
const delay_ms = Math.min(2000 * attempt, 8000);
console.log(`API GET: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`);
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};

View File

@@ -13,7 +13,7 @@ export const patch_object = async function patch_object({
headers = {},
params = {},
data = {},
timeout = 60000,
timeout = 20000,
return_meta = false,
log_lvl = 0,
retry_count = 5
@@ -82,7 +82,6 @@ export const patch_object = async function patch_object({
const is_valid_bypass =
bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
if (is_valid_bypass) {
@@ -154,9 +153,15 @@ export const patch_object = async function patch_object({
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
// Keep timeout handle at attempt scope so catch can always clear it.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
// AbortError alone is ambiguous. Track whether the helper timeout
// fired so we can retry timeout-aborts but fail fast on caller abort.
let did_timeout_abort = false;
timeoutId = setTimeout(() => {
did_timeout_abort = true;
console.error(
`API PATCH request timed out after ${timeout}ms.`
);
@@ -174,12 +179,52 @@ export const patch_object = async function patch_object({
url.toString(),
fetchOptions
).catch(function (error: any) {
// Keep noisy abort/network conditions out of high-level logs.
if (
error?.name === 'AbortError' ||
error?.name === 'TypeError' ||
error?.message?.includes('aborted')
) {
if (log_lvl > 1) {
console.log(
'API PATCH: Request aborted or browser-terminated.',
error
);
}
return error;
}
console.log(
'API PATCH Object *fetch* request was aborted or failed in an unexpected way.',
error
);
return error;
});
clearTimeout(timeoutId);
if (timeoutId) clearTimeout(timeoutId);
// Error object was returned from fetch catch block; decide retry class.
if (
response instanceof Error ||
(response &&
(response.name === 'AbortError' ||
response.name === 'TypeError'))
) {
if (response.name === 'AbortError') {
// Retry only helper-timeout aborts. Caller/navigation aborts
// should fail fast to avoid duplicate mutation side-effects.
if (did_timeout_abort) {
throw new Error(
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
);
}
return false;
}
// Transient browser/network failure class.
throw new Error(
`Network error (attempt ${attempt}): ${response.message}`
);
}
if (!response) {
throw new Error(
@@ -293,6 +338,8 @@ export const patch_object = async function patch_object({
? json.data
: json;
} catch (error) {
// Ensure per-attempt timeout is always cleared on failure.
if (timeoutId) clearTimeout(timeoutId);
console.error(`API PATCH error on attempt ${attempt}:`, error);
if (attempt === retry_count) {
@@ -300,9 +347,12 @@ export const patch_object = async function patch_object({
return false;
}
if (log_lvl) {
console.log(`Retrying... (${attempt}/${retry_count})`);
}
// Backoff before retrying. Caps at 8s to match GET/POST policy.
const delay_ms = Math.min(2000 * attempt, 8000);
console.log(
`API PATCH: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`
);
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};

View File

@@ -3,6 +3,22 @@ import { post_object } from './api_post_object';
import { patch_object } from './api_patch_object';
import { delete_object } from './api_delete_object';
const JSON_PRETTY_SPACES = 2;
function serialize_json_field_pretty(value: unknown) {
if (value === null || value === undefined) return value;
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, JSON_PRETTY_SPACES);
} catch {
return value;
}
}
return JSON.stringify(value, null, JSON_PRETTY_SPACES);
}
/**
* --- POST (CREATE) WRAPPERS ---
*/
@@ -33,13 +49,11 @@ export async function create_ae_obj({
// Standard Aether Pattern: Auto-serialize any key ending in _json
const cleaned_fields = { ...fields };
for (const key in cleaned_fields) {
if (
key.endsWith('_json') &&
cleaned_fields[key] !== null &&
typeof cleaned_fields[key] === 'object'
) {
if (key.endsWith('_json') && cleaned_fields[key] !== null) {
if (log_lvl) console.log(`Auto-serializing field: ${key}`);
cleaned_fields[key] = JSON.stringify(cleaned_fields[key]);
cleaned_fields[key] = serialize_json_field_pretty(
cleaned_fields[key]
);
}
}
@@ -98,12 +112,10 @@ export async function create_nested_obj({
// Standard Aether Pattern: Auto-serialize any key ending in _json
const cleaned_fields = { ...fields };
for (const key in cleaned_fields) {
if (
key.endsWith('_json') &&
cleaned_fields[key] !== null &&
typeof cleaned_fields[key] === 'object'
) {
cleaned_fields[key] = JSON.stringify(cleaned_fields[key]);
if (key.endsWith('_json') && cleaned_fields[key] !== null) {
cleaned_fields[key] = serialize_json_field_pretty(
cleaned_fields[key]
);
}
}
@@ -148,13 +160,11 @@ export async function update_ae_obj({
// Standard Aether Pattern: Auto-serialize any key ending in _json
const cleaned_fields = { ...fields };
for (const key in cleaned_fields) {
if (
key.endsWith('_json') &&
cleaned_fields[key] !== null &&
typeof cleaned_fields[key] === 'object'
) {
if (key.endsWith('_json') && cleaned_fields[key] !== null) {
if (log_lvl > 1) console.log(`Auto-serializing field: ${key}`);
cleaned_fields[key] = JSON.stringify(cleaned_fields[key]);
cleaned_fields[key] = serialize_json_field_pretty(
cleaned_fields[key]
);
}
}
@@ -214,12 +224,10 @@ export async function update_nested_obj({
// Standard Aether Pattern: Auto-serialize any key ending in _json
const cleaned_fields = { ...fields };
for (const key in cleaned_fields) {
if (
key.endsWith('_json') &&
cleaned_fields[key] !== null &&
typeof cleaned_fields[key] === 'object'
) {
cleaned_fields[key] = JSON.stringify(cleaned_fields[key]);
if (key.endsWith('_json') && cleaned_fields[key] !== null) {
cleaned_fields[key] = serialize_json_field_pretty(
cleaned_fields[key]
);
}
}

View File

@@ -15,7 +15,7 @@ export const post_object = async function post_object({
params = {},
data = {},
form_data = null,
timeout = 90000,
timeout = 20000,
return_meta = false,
return_blob = false,
filename = '',
@@ -23,7 +23,10 @@ export const post_object = async function post_object({
// The task_id value should be a random string that is unique to the task. This is used to identify the task in the message event.
task_id = crypto.randomUUID(),
log_lvl = 0,
retry_count = 5
retry_count = 5,
// When true: use XHR instead of fetch so xhr.upload.onprogress can fire
// progress postMessages into api_upload_kv. Only meaningful for form_data uploads.
track_progress = false
}: {
api_cfg: any;
endpoint: string;
@@ -39,6 +42,7 @@ export const post_object = async function post_object({
task_id?: string;
log_lvl?: number;
retry_count?: number;
track_progress?: boolean;
}) {
if (log_lvl) {
console.log(
@@ -104,7 +108,6 @@ export const post_object = async function post_object({
const is_valid_bypass =
bypass_val === 'bypass' ||
bypass_val === 'Nothing to See Here' ||
params['key'] ||
bypass_val === 'direct-download';
if (is_valid_bypass) {
@@ -181,14 +184,35 @@ export const post_object = async function post_object({
fetch_method = api_cfg.fetch;
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.error(`API POST request timed out after ${timeout}ms.`);
controller.abort();
}, timeout);
// XHR path — only for form_data uploads with progress tracking requested.
// fetch() has no upload progress events; XHR.upload.onprogress does.
if (track_progress && form_data) {
return _post_with_xhr({
url_str: url.toString(),
headers_cleaned,
form_data,
task_id,
endpoint,
timeout,
return_meta,
log_lvl
});
}
for (let attempt = 1; attempt <= retry_count; attempt++) {
// Declared at loop scope (not inside try) so the catch block can clearTimeout.
// Fresh controller per attempt — same rationale as api_get_object.ts.
const controller = new AbortController();
// AbortError is not specific enough by itself. Distinguish timeout-aborts
// (retryable transient class) from intentional caller aborts (fail-fast).
let did_timeout_abort = false;
const timeoutId = setTimeout(() => {
did_timeout_abort = true;
console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`);
controller.abort();
}, timeout);
try {
const fetchOptions: RequestInit = {
method: 'POST',
headers: headers_cleaned,
@@ -227,19 +251,28 @@ export const post_object = async function post_object({
});
clearTimeout(timeoutId);
// Check if we should stop due to abort or network failure
// Check if we should stop due to abort or network failure.
if (
response instanceof Error ||
(response &&
(response.name === 'TypeError' ||
response.name === 'AbortError'))
) {
if (response.name === 'AbortError') return false;
if (log_lvl > 1)
console.log(
'API POST Object: Detected NetworkError or TypeError. Failing fast.'
);
return false;
// Retry timeout-aborts from this helper; do not retry caller aborts
// (route change/unmount/manual cancellation).
if (response.name === 'AbortError') {
if (did_timeout_abort) {
throw new Error(
`Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms`
);
}
return false;
}
// TypeError = transient network failure. Throw into the retry loop
// so backoff-and-retry applies. Same fix as api_get_object.ts — see
// comment there for the full history of why this was broken.
throw new Error(`Network error (attempt ${attempt}): ${response.message}`);
}
if (!response) {
@@ -393,6 +426,8 @@ export const post_object = async function post_object({
}
}
} catch (error) {
// Ensure the per-attempt timeout timer is always cancelled on failure.
clearTimeout(timeoutId);
console.error(`API POST error on attempt ${attempt}:`, error);
if (attempt === retry_count) {
@@ -400,9 +435,134 @@ export const post_object = async function post_object({
return false;
}
if (log_lvl) {
console.log(`Retrying... (${attempt}/${retry_count})`);
}
// Backoff before retrying — same rationale as api_get_object.ts.
const delay_ms = Math.min(2000 * attempt, 8000);
console.log(`API POST: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`);
await new Promise<void>((resolve) => setTimeout(resolve, delay_ms));
}
}
};
function _post_with_xhr({
url_str,
headers_cleaned,
form_data,
task_id,
endpoint,
timeout,
return_meta,
log_lvl
}: {
url_str: string;
headers_cleaned: key_val;
form_data: FormData;
task_id: string;
endpoint: string;
timeout: number;
return_meta: boolean;
log_lvl: number;
}): Promise<any> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url_str);
xhr.timeout = timeout;
// Apply auth/custom headers. Content-Type is intentionally omitted —
// the browser sets the multipart boundary automatically for FormData.
for (const [key, value] of Object.entries(headers_cleaned)) {
xhr.setRequestHeader(key, String(value));
}
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && typeof window !== 'undefined') {
const pct = Math.round((event.loaded / event.total) * 100);
try {
window.postMessage(
{
type: 'api_post_json_form',
status: 'uploading',
task_id,
endpoint,
size_total: event.total,
size_loaded: event.loaded,
percent_completed: pct,
progress: pct,
rate: 0
},
'*'
);
} catch (_) {}
}
};
xhr.ontimeout = () => {
console.error(`XHR upload timed out after ${timeout}ms. Endpoint: ${endpoint}`);
resolve(false);
};
xhr.onerror = () => {
console.error(`XHR upload network error. Endpoint: ${endpoint}`);
resolve(false);
};
xhr.onload = () => {
if (log_lvl)
console.log(`XHR response: status=${xhr.status} endpoint=${endpoint}`);
if (xhr.status === 401 || xhr.status === 403) {
console.warn(`XHR AUTH FAILURE (${xhr.status}): ${endpoint}`);
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
resolve(false);
return;
}
if (xhr.status === 404) {
resolve(null);
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
console.error(`XHR upload failed: HTTP ${xhr.status}. Endpoint: ${endpoint}`);
resolve(false);
return;
}
// Fire completion postMessage (matches the fetch path shape)
try {
if (typeof window !== 'undefined') {
window.postMessage(
{
type: 'api_post_json_form',
status: 'complete',
task_id,
endpoint,
size_total: 0,
size_loaded: 0,
percent_completed: 100,
progress: 100,
rate: 0
},
'*'
);
}
} catch (_) {}
try {
const json = JSON.parse(xhr.responseText);
if (log_lvl > 1) console.log('XHR Response JSON:', json);
resolve(
return_meta
? json
: json.data !== undefined
? json.data
: json
);
} catch (e) {
console.error('XHR: Failed to parse response JSON.', e);
resolve(false);
}
};
xhr.send(form_data);
});
}

View File

@@ -192,7 +192,7 @@ export async function load_ae_obj_li__archive({
if (inc_content_li && archive_obj_li && Array.isArray(archive_obj_li)) {
for (let i = 0; i < archive_obj_li.length; i++) {
const archive_obj = archive_obj_li[i];
const archive_id = archive_obj.archive_id_random;
const archive_id = archive_obj.archive_id;
const content_li = await load_ae_obj_li__archive_content({
api_cfg: api_cfg,

View File

@@ -1,6 +1,5 @@
export const editable_fields__archive_content = [
'archive_id',
'archive_id_random',
'archive_content_type',
'name',
'description',
@@ -9,7 +8,6 @@ export const editable_fields__archive_content = [
'url',
'url_text',
'hosted_file_id',
'hosted_file_id_random',
'file_path',
'filename',
'file_extension',

View File

@@ -299,6 +299,7 @@ export const properties_to_save = [
'archive_id',
'archive_content_type',
'name',
'code',
'description',
'content_html',
'content_json',

View File

@@ -90,6 +90,7 @@ export interface Archive_Content {
archive_content_type: string;
name: string;
code?: null | string;
description?: null | string;
content_html?: null | string;
@@ -168,7 +169,28 @@ export class MySubClassedDexie extends Dexie {
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on, [group+priority+sort+updated_on]`
});
// v2: add code index to content table
this.version(2).stores({
archive: `
id, archive_id,
code,
account_id,
name,
original_datetime, original_timezone, original_location,
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on`,
content: `
id, archive_content_id,
archive_id,
archive_content_type,
name,
code,
hosted_file_id,
original_datetime, original_timezone, original_location,
[group+original_datetime],
tmp_sort_1, tmp_sort_2,
enable, hide, priority, sort, group, notes, created_on, updated_on, [group+priority+sort+updated_on]`
});
}
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
// *** Import Svelte specific
import * as Lucide from 'lucide-svelte';
import * as Lucide from '@lucide/svelte';
import { fade } from 'svelte/transition';
// *** Import Aether specific variables and functions
@@ -35,6 +35,7 @@ interface Props {
require_auth?: boolean;
classes?: string;
click?: () => void | Promise<any>;
track_click_promise?: boolean;
label?: import('svelte').Snippet;
}
@@ -57,6 +58,7 @@ let {
require_auth = true,
classes = '',
click,
track_click_promise = true,
label
}: Props = $props();
@@ -128,10 +130,7 @@ $effect(() => {
let ae_promises: key_val = $state({});
$effect(() => {
const file_id =
hosted_file_obj?.id ||
hosted_file_obj?.hosted_file_id ||
hosted_file_id;
const file_id = hosted_file_obj?.hosted_file_id ?? hosted_file_id;
if (file_id && $ae_sess?.api_download_kv[file_id]?.percent_completed) {
download_percent = $ae_sess.api_download_kv[file_id].percent_completed;
}
@@ -139,10 +138,7 @@ $effect(() => {
// Reactive timer to alternate views during active download
$effect(() => {
const file_id =
hosted_file_obj?.id ||
hosted_file_obj?.hosted_file_id ||
hosted_file_id;
const file_id = hosted_file_obj?.hosted_file_id ?? hosted_file_id;
const is_actively_downloading =
ae_promises[file_id] && download_complete === undefined;
@@ -178,32 +174,50 @@ let shortened_filename = $derived(
})
);
let is_url_file = $derived.by(() => {
const raw_filename = (hosted_file_obj?.filename ?? '').toLowerCase();
const extension = (hosted_file_obj?.extension ?? '').toLowerCase();
return (
raw_filename.startsWith('http://') ||
raw_filename.startsWith('https://') ||
extension === 'url'
);
});
let direct_download_url = $derived.by(() => {
if (!show_direct_download || !hosted_file_obj) return '';
// IMPORTANT: For Direct Link Mode, we MUST use the V3 Action endpoint to support Random String IDs.
// Legacy endpoints often expect integer IDs and will return 404 for string IDs.
const file_id =
hosted_file_obj.event_file_id ||
hosted_file_obj.hosted_file_id ||
hosted_file_id;
const obj_type_path = hosted_file_obj.event_file_id
? 'event_file'
: 'hosted_file';
return `${$ae_api.base_url}/v3/action/${obj_type_path}/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`;
// Use event_file endpoint when event_file_id is present (canonical per API guide §5).
// Fall back to hosted_file endpoint for standalone hosted_file objects.
if (hosted_file_obj.event_file_id) {
return `${$ae_api.base_url}/v3/action/event_file/${hosted_file_obj.event_file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`;
}
const file_id = hosted_file_obj.hosted_file_id || hosted_file_id;
return `${$ae_api.base_url}/v3/action/hosted_file/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`;
});
async function handle_click() {
const file_id =
hosted_file_obj?.id ||
hosted_file_obj?.hosted_file_id ||
hosted_file_id;
const file_id = hosted_file_obj?.hosted_file_id ?? hosted_file_id;
// URL-backed records are intentionally not downloaded. Callers are expected
// to provide a custom click handler that opens the URL directly.
if (is_url_file) {
if (click) {
const result = click();
if (track_click_promise && result instanceof Promise) {
ae_promises[file_id] = result;
}
}
return;
}
download_complete = undefined;
download_status_msg = 'Downloading...';
if (click) {
const result = click();
// If the override returns a promise, track it so the UI shows progress
if (result instanceof Promise) {
// If the override returns a promise, track it so the UI shows progress.
// Launcher open flows can opt out so native status messages stay authoritative.
if (track_click_promise && result instanceof Promise) {
ae_promises[file_id] = result;
}
return;
@@ -238,10 +252,7 @@ async function handle_click() {
</script>
{#snippet content()}
{@const file_id =
hosted_file_obj?.id ||
hosted_file_obj?.hosted_file_id ||
hosted_file_id}
{@const file_id = hosted_file_obj?.hosted_file_id ?? hosted_file_id}
{#await ae_promises[file_id]}
<div class="flex min-h-[1.5rem] w-full items-center">
<div
@@ -316,8 +327,7 @@ async function handle_click() {
{/snippet}
{#if hosted_file_id && hosted_file_obj}
{@const file_id =
hosted_file_obj.id || hosted_file_obj.hosted_file_id || hosted_file_id}
{@const file_id = hosted_file_obj.hosted_file_id ?? hosted_file_id}
{#if show_direct_download}
<a
@@ -333,7 +343,20 @@ async function handle_click() {
disabled={require_auth && !$ae_loc.authenticated_access}
class={variant_classes}
onclick={handle_click}
title={`Download this file:\n${final_filename}\n[API] SHA256: ${hosted_file_obj?.hash_sha256?.slice(0, 10)}...\nHosted ID: ${file_id}\n Linked to: ${linked_to_type} ID: ${linked_to_id}`}>
title={
`Download this file:
${final_filename}
[API] SHA256: ${hosted_file_obj?.hash_sha256?.slice(0, 10)}...
Hosted ID: ${file_id}
File size: ${hosted_file_obj.file_size ? ae_util.format_bytes(hosted_file_obj.file_size) : 'Unknown size'}
Created on: ${ae_util.iso_datetime_formatter(hosted_file_obj.created_on, 'datetime_short')}
Updated on: ${ae_util.iso_datetime_formatter(hosted_file_obj.updated_on, 'datetime_short')}
Open with: ${hosted_file_obj.open_in_os == 'win' ? 'Windows' : hosted_file_obj.open_in_os == 'mac' ? 'macOS' : hosted_file_obj.open_in_os == 'linux' ? 'Linux' : '--not set--'}
Linked to Type: ${linked_to_type ?? '--none--'} ID: ${linked_to_id ?? '---'}`
}>
{@render content()}
</button>
{/if}

View File

@@ -2,7 +2,7 @@
// untrack import removed — task_id sync now uses direct $effect (no untrack needed)
// Imports
// Import components and elements
import * as Lucide from 'lucide-svelte';
import * as Lucide from '@lucide/svelte';
import Element_input_files_tbl from '$lib/elements/element_input_files_tbl.svelte';
// Import storage, functions, and libraries
@@ -49,7 +49,7 @@ let {
input_name = 'file_list',
multiple = true,
required = true,
accept = 'audio/*, image/*, video/*, .bak, .cfg, .css, .csv, .doc, .docx, .gz, .htm, .html, .ini, .iso, .j2, .json, .key, .keynote, .md, .pdf, .ppt, .pptx, .rar, .rtf, .sql, .svelte, ttf, .txt, .xls, .xlsx, .xz, .zip, .bin, .dmg, .exe, .js, .msi, .php, .py, .sh',
accept = 'audio/*, image/*, video/*, .bak, .cfg, .css, .csv, .doc, .docx, .gz, .htm, .html, .ini, .iso, .j2, .json, .key, .keynote, .md, .pdf, .ppt, .pptx, .rar, .rtf, .sql, .svelte, .ttf, .txt, .xls, .xlsx, .xz, .zip, .bin, .dmg, .exe, .js, .msi, .php, .py, .sh',
class_li_default = 'flex flex-col gap-1 items-center justify-center w-full max-w-2xl mx-auto my-1',
class_li = '',
input_class_li = ['file_drop_area'],
@@ -66,7 +66,7 @@ let {
let task_id: string = $state('');
let input_file_list: any = $state(null);
let ae_promises: key_val = $state({}); // Promise<any>;
let ae_triggers: key_val = {};
// let ae_triggers: key_val = {};
let input_element_id = 'ae_comp__hosted_files_upload__input';
@@ -78,18 +78,22 @@ $effect(() => {
});
$effect(() => {
// Sync task_id with link_to_id prop so it resets when navigating to a different object.
task_id = link_to_id;
// Only sync task_id when idle — don't reset during an in-flight upload.
if (!ae_promises.upload__hosted_file_obj) {
task_id = link_to_id;
}
});
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
// *** Functions and Logic
async function handle_submit_form_files(event: SubmitEvent) {
console.log('*** handle_submit_form() ***');
event.preventDefault();
if (!event) {
return;
}
if (log_lvl) console.log('*** handle_submit_form() ***');
$ae_sess.files.disable_submit__hosted_file_obj = true;
$ae_sess.files.submit_status = 'saving';
@@ -113,42 +117,17 @@ async function handle_submit_form_files(event: SubmitEvent) {
file_input.files &&
file_input.files.length > 0
) {
task_id = link_to_id; // Ideally this should be the file hash, but we may be uploading multiple files at once. This should be done with a loop instead?
// Loop through each file and upload them individually in event.target[input_element_id].files
// The task_id should be the file hash.
// processed_file_list[i] has the file hash_sha256, hash_sha256_match, warnings, uploaded, uploaded_bytes, filename, and file_size_bytes.
for (let i = 0; i < file_input.files.length; i++) {
let tmp_file = file_input.files[i];
task_id = $ae_sess.files.processed_file_list[i].hash_sha256;
// hosted_file_results = await handle_input_upload_files([tmp_file], task_id);
hosted_file_results = await handle_input_upload_files({
input_upload_files: [tmp_file],
input_upload_files: [file_input.files[i]],
task_id: task_id
});
if (hosted_file_results) {
console.log(`hosted_file_results:`, hosted_file_results);
} else {
console.log(`hosted_file_results:`, hosted_file_results);
}
if (log_lvl > 1) console.log('hosted_file_results:', hosted_file_results);
}
// hosted_file_results = await handle_input_upload_files(event.target[input_element_id].files, task_id);
$ae_sess.files.processed_file_list = [];
$ae_sess = $ae_sess; // Is this needed? 2025-03-17
target.reset();
// await tick();
if (log_lvl) {
console.log(
`hosted_file_id_li: ${hosted_file_id_li}`,
hosted_file_id_li
);
} else if (log_lvl > 1) {
console.log('hosted_file_results:', hosted_file_results);
}
if (log_lvl) console.log('hosted_file_id_li:', hosted_file_id_li);
}
$ae_sess.files.disable_submit__hosted_file_obj = false;
@@ -164,120 +143,72 @@ async function handle_input_upload_files({
input_upload_files: any[];
task_id: string;
}) {
console.log('*** handle_input_upload_files() ***');
if (log_lvl) console.log('*** handle_input_upload_files() ***');
const form_data = new FormData();
form_data.append('account_id', $ae_loc.account_id);
form_data.append('link_to_type', link_to_type);
form_data.append('link_to_id', link_to_id);
for (let i = 0; i < input_upload_files.length; i++) {
form_data.append(`file_list`, input_upload_files[i]);
form_data.append('file_list', input_upload_files[i]);
}
// hash_sha256, uploaded, uploaded_bytes
// $ae_sess.files.processed_file_list[i] = {
// ...$ae_sess.files.processed_file_list[i],
// uploaded: $ae_sess.api_upload_kv[link_to_id].percent_completed,
// uploaded_bytes: $ae_sess.api_upload_kv[link_to_id].uploaded_bytes,
// };
let params = null;
let endpoint = '/v3/action/hosted_file/upload';
console.log(form_data);
params = null;
// Uncomment and the post_promise is not seen by the "await" below
// post_promise = await api.post_object({api_cfg: $cfg.api, endpoint: endpoint, params: params, data:form_data});
// Uncomment so that the post_promise is not seen by the "await" below
// Promise assigned to state so {#await ae_promises.upload__hosted_file_obj} in the
// template can track it. Using await here instead would hide the promise from the template.
ae_promises.upload__hosted_file_obj = api
.post_object({
api_cfg: $ae_api,
endpoint: endpoint,
// params: params,
endpoint: '/v3/action/hosted_file/upload',
form_data: form_data,
timeout: 1200000, // 20 min — large video/audio files
task_id: task_id,
track_progress: true,
log_lvl: log_lvl
// retry_count: 1,
})
.then(async function (result) {
// WARNING!!!! ONLY ONE FILE IS EXPECTED TO BE UPLOADED AT A TIME!!!
// NOTE: The upload endpoint always returns a list of successfully uploaded files. In this case we are only uploading one file and expecting a list of one item.
let x = 0;
console.log(result[x]);
let hosted_file_obj = result[x];
let hosted_file_id = hosted_file_obj.hosted_file_id;
.then(function (result) {
// Endpoint always returns a list; we upload one file at a time.
if (!result || !result[0]) {
console.error('Upload failed — no result returned.');
return false;
}
const hosted_file_obj = result[0];
const hosted_file_id = hosted_file_obj.hosted_file_id;
hosted_file_id_li.push(hosted_file_id);
hosted_file_obj_li.push(hosted_file_obj);
let hosted_file_data: key_val = {};
hosted_file_data['id'] = hosted_file_id; // Same as the hosted_file_id
hosted_file_data['hosted_file_id'] = hosted_file_id;
hosted_file_data['for_type'] = link_to_type;
hosted_file_data['for_id'] = link_to_id;
hosted_file_data['hash_sha256'] = hosted_file_obj.hash_sha256;
hosted_file_data['filename'] = hosted_file_obj.filename;
hosted_file_data['extension'] = hosted_file_obj.extension;
hosted_file_data['content_type'] = hosted_file_obj.content_type;
hosted_file_data['size'] = hosted_file_obj.size;
hosted_file_data['enable'] = true;
hosted_file_data['created_on'] = hosted_file_obj.created_on;
hosted_file_data['updated_on'] = hosted_file_obj.updated_on;
console.log(hosted_file_data);
const hosted_file_data: key_val = {
id: hosted_file_id,
hosted_file_id: hosted_file_id,
for_type: link_to_type,
for_id: link_to_id,
hash_sha256: hosted_file_obj.hash_sha256,
filename: hosted_file_obj.filename,
extension: hosted_file_obj.extension,
content_type: hosted_file_obj.content_type,
size: hosted_file_obj.size,
enable: true,
created_on: hosted_file_obj.created_on,
updated_on: hosted_file_obj.updated_on
};
hosted_file_obj_kv[hosted_file_id] = hosted_file_data;
if (log_lvl) {
console.log(`hosted_file_data:`, hosted_file_data);
}
if (log_lvl) console.log('hosted_file_data:', hosted_file_data);
return hosted_file_data;
// $ae_sess.files.new_upload_list[i].uploaded_bytes = 10; // fake 10 bytes at least...
// let event_file_id = await events_func.create_hosted_file_obj_from_hosted_file_async({
// api_cfg: $ae_api,
// hosted_file_id: hosted_file_id,
// data: event_file_data,
// log_lvl: log_lvl
// })
// .then(function (create_result) {
// console.log(create_result); // NOTE: This should be the event_file_id string
// // let event_file_id = create_result;
// return create_result;
// });
// return event_file_id;
})
// .then(function (hosted_file_data) {
// return hosted_file_data;
// })
.catch(function (error: any) {
console.log('Something went wrong.');
console.log(error);
console.error('Upload failed:', error);
return false;
})
.finally(function () {
$slct_trigger = 'load__hosted_file_obj_li';
});
if (log_lvl) {
console.log(`Waiting for upload__hosted_file_obj promise...`);
}
let hosted_file_result = ae_promises.upload__hosted_file_obj;
return hosted_file_result;
return ae_promises.upload__hosted_file_obj;
}
</script>
<!-- class:hidden={!$ae_loc.trusted_access} -->
<form onsubmit={handle_submit_form_files} class="{class_li_default} {class_li}">
<form onsubmit={prevent_default(handle_submit_form_files)} class="{class_li_default} {class_li}">
{#await ae_promises.upload__hosted_file_obj}
<div class="flex flex-row items-center justify-center gap-1 text-lg">
<Lucide.LoaderCircle class="m-1 animate-spin" />
@@ -291,7 +222,7 @@ async function handle_input_upload_files({
{/await}
<label
for="ae_comp__hosted_files_upload__input"
for={input_element_id}
class="svelte_input_file_label text-center"
class:hidden={$ae_sess.files.disable_submit__hosted_file_obj}>
{#if label}{@render label()}{:else}
@@ -337,9 +268,19 @@ async function handle_input_upload_files({
<button
type="submit"
class="btn btn-lg btn-primary preset-tonal-primary border-primary-500 hover:preset-tonal-success hover:border-success-500 w-54 border"
class="
btn btn-lg btn-primary
preset-tonal-primary
border border-primary-500
hover:preset-tonal-success hover:border-success-500
w-54
transition-all
"
class:opacity-30={$ae_sess.files.disable_submit__hosted_file_obj ||
$ae_sess.files.status__file_list != 'ready'}
disabled={$ae_sess.files.disable_submit__hosted_file_obj ||
$ae_sess.files.status__file_list != 'ready'}>
$ae_sess.files.status__file_list != 'ready'}
>
{#await ae_promises.upload__hosted_file_obj}
<Lucide.LoaderCircle class="m-1 animate-spin" />
<span class="">
@@ -350,18 +291,18 @@ async function handle_input_upload_files({
{/if}
</span>
{:then}
<Lucide.UploadCloud class="m-1" size={20} />
<Lucide.CloudUpload class="m-1" size={20} />
<span class="text-sm"> Upload </span>
{#if $ae_sess.files.processed_file_list?.length > 0}
<span class="ml-2 grow font-bold">
{#if $ae_sess.files.processed_file_list?.length > 0}
{$ae_sess.files.processed_file_list.length}
{$ae_sess.files.processed_file_list.length === 1
? 'file'
: 'files'}
{:else}
<span class="text-xs"> 0 </span>
{/if}
</span>
{:else}
<span class="text-xs"> none </span>
{/if}
{/await}
</button>
</form>

View File

@@ -5,11 +5,12 @@ import { untrack } from 'svelte';
* Specialized UI for managing site.cfg_json settings.
* Supports General, AI, Performance, and IDAA-specific configurations.
*/
import { Modal } from 'flowbite-svelte';
import {
Brain,
CodeXml,
ExternalLink,
Eye,
EyeOff,
Globe,
Mail,
Minus,
@@ -34,7 +35,7 @@ $effect(() => {
if (typeof cfg_json === 'string') {
try {
cfg_json = JSON.parse(cfg_json);
} catch (e) {
} catch {
cfg_json = {};
}
}
@@ -45,13 +46,14 @@ $effect(() => {
let active_tab: 'visuals' | 'email' | 'ai' | 'refresh' | 'idaa' | 'raw' =
$state('visuals');
let raw_json_str = $state('');
let show_llm_api_token = $state(false);
// Ensure we have a valid object
if (!cfg_json) cfg_json = {};
function add_to_list(key: string) {
function add_to_list(key: string, prompt_label: string) {
if (!cfg_json[key]) cfg_json[key] = [];
const val = prompt('Enter Novi UUID:');
const val = prompt(prompt_label);
if (val) cfg_json[key].push(val);
}
@@ -59,6 +61,10 @@ function remove_from_list(key: string, index: number) {
cfg_json[key].splice(index, 1);
}
function list_count(key: string): number {
return Array.isArray(cfg_json[key]) ? cfg_json[key].length : 0;
}
// Sync Raw JSON string when entering the tab
$effect(() => {
if (active_tab === 'raw') {
@@ -74,7 +80,7 @@ $effect(() => {
try {
const parsed = JSON.parse(raw_json_str);
cfg_json = parsed;
} catch (e) {
} catch {
// Ignore invalid JSON while typing
}
}
@@ -236,6 +242,14 @@ $effect(() => {
>LLM Model</span>
<input
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="site_llm_model"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={cfg_json.llm__api_model}
class="input variant-form-material" />
</label>
@@ -243,10 +257,34 @@ $effect(() => {
<label class="label">
<span class="text-xs font-bold uppercase opacity-50"
>API Token</span>
<input
type="password"
bind:value={cfg_json.llm__api_token}
class="input variant-form-material font-mono" />
<div class="flex gap-2">
<input
type={show_llm_api_token ? 'text' : 'password'}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="site_llm_api_token"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={cfg_json.llm__api_token}
class="input variant-form-material grow font-mono" />
<button
type="button"
class="btn btn-sm variant-soft-surface"
onclick={() =>
(show_llm_api_token = !show_llm_api_token)}
title={show_llm_api_token
? 'Hide API Token'
: 'Show API Token'}>
{#if show_llm_api_token}
<EyeOff size="1.1em" />
{:else}
<Eye size="1.1em" />
{/if}
</button>
</div>
</label>
<label class="label">
<span class="text-xs font-bold uppercase opacity-50"
@@ -331,24 +369,32 @@ $effect(() => {
<!-- UUID Lists -->
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500' }] as list (list.key)}
<div class="bg-surface-500/5 space-y-2 rounded-lg p-3">
<header class="flex items-center justify-between">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500', prompt: 'Enter Group GUID:' }, { key: 'jitsi_exclude_uuids', label: 'Jitsi Excluded UUIDs', color: 'text-error-500', prompt: 'Enter Novi UUID to exclude:' }, { key: 'jitsi_known_meetings', label: 'Known IDAA Meetings', color: 'text-primary-500', prompt: 'Enter meeting name to allow:' }] as list (list.key)}
<details class="bg-surface-500/5 rounded-lg p-3">
<summary
class="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
<span class="min-w-0">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
<span class="ml-2 text-[10px] opacity-50"
>({list_count(list.key)})</span>
</span>
<button
class="btn btn-icon btn-icon-sm variant-soft-primary"
onclick={() => add_to_list(list.key)}>
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
add_to_list(list.key, list.prompt);
}}>
<Plus size="12" />
</button>
</header>
<div class="space-y-1">
{#each cfg_json[list.key] ?? [] as uuid, i (uuid)}
</summary>
<div class="mt-3 space-y-1">
{#each cfg_json[list.key] ?? [] as item, i (i)}
<div
class="bg-surface-500/10 flex items-center gap-1 rounded p-1 font-mono text-[10px]">
<span class="grow truncate"
>{uuid}</span>
<span class="grow truncate">{item}</span>
<button
class="text-error-500 transition-transform hover:scale-110"
onclick={() =>
@@ -358,7 +404,7 @@ $effect(() => {
</div>
{/each}
</div>
</div>
</details>
{/each}
</section>

View File

@@ -270,6 +270,9 @@ export const properties_to_save = [
'user_id',
'user_id_random',
'external_client_id',
'url_root',
'url_full_path',
'url_params',
'source',
'object_type',
'object_id',

View File

@@ -221,23 +221,37 @@ async function _refresh_site_domain_background({
});
// WHY: The fast-path returns stale Dexie cache, then this background refresh
// runs after the page renders. If cfg_json changed server-side (e.g. a Novi
// API key was added), the stale cfg is already in $ae_loc. We push the fresh
// cfg_json into the store here so any layout tracking it (e.g. IDAA Novi
// verification) gets notified and can retry with the correct config.
// runs after the page renders. Push any fields that may have been missing from
// the stale cache (e.g. account_name, cfg_json) back into $ae_loc so the UI
// reflects the correct values without requiring a second full page reload.
const loc_patch: Record<string, unknown> = {};
if (result.cfg_json) {
const current_cfg = get(ae_loc).site_cfg_json;
if (
JSON.stringify(current_cfg) !==
JSON.stringify(result.cfg_json)
) {
ae_loc.update((loc) => ({
...loc,
site_cfg_json: result.cfg_json
}));
if (JSON.stringify(current_cfg) !== JSON.stringify(result.cfg_json)) {
loc_patch.site_cfg_json = result.cfg_json;
}
}
if (result.account_name) {
const current_name = get(ae_loc).account_name;
// Only overwrite the default placeholder — don't stomp a real value.
if (!current_name || current_name === 'Account Name Not Set' || current_name === 'Ghost Account') {
loc_patch.account_name = result.account_name;
}
}
if (result.account_code) {
const current_code = get(ae_loc).account_code;
if (!current_code || current_code === 'not_set' || current_code === 'ghost') {
loc_patch.account_code = result.account_code;
}
}
if (Object.keys(loc_patch).length > 0) {
ae_loc.update((loc) => ({ ...loc, ...loc_patch }));
}
return result;
}
} catch (error: any) {

View File

@@ -328,117 +328,76 @@ export async function delete_ae_obj_id__user({
}
/*
* *** LEGACY AUTHENTICATION HEADER LOGIC ***
* *** V3 AUTHENTICATION FUNCTIONS ***
*
* The functions in this section interact with legacy Aether API authentication endpoints
* (e.g., /user/authenticate, /user/lookup_email).
* All functions below use the V3 action endpoints:
* POST /v3/action/user/authenticate (was: GET /user/authenticate)
* GET /v3/action/user/{id}/email_auth_key_url (was: GET /user/{id}/email_auth_key_url)
* POST /v3/crud/user/search (was: GET /user/lookup_email)
* POST /v3/action/user/{id}/change_password (was: PATCH /user/{id}/change_password)
*
* Unlike V3 endpoints which handle context automatically or via standard headers,
* these legacy endpoints have specific requirements:
*
* 1. They often require the `x-account-id` header to be explicitly set to the target
* account ID to find the user within that specific account context.
* 2. The standard API wrapper logic might strip `x-account-id` if `x-no-account-id`
* is present (Bootstrap Paradox logic). We must explicitly remove `x-no-account-id`
* and set `x-account-id` to ensure the request is routed correctly.
* 3. Some endpoints accept `account_id` as a query parameter, while others (like email sending)
* may crash (500 Error) if unexpected parameters are passed.
* Key differences from the legacy routes:
* - Credentials are in the POST body, not query params (safer — not logged in URLs)
* - x-account-id header still required to scope username/email lookups to the account
* - x-no-account-id must still be removed when we have a real account context
*/
// Updated 2025-04-04
// This function handles username/password authentication.
// It explicitly sets the x-account-id header to ensure the user is looked up in the correct account.
// Updated 2026-04-25 — migrated from GET /user/authenticate to POST /v3/action/user/authenticate
export async function auth_ae_obj__username_password({
api_cfg,
account_id,
null_account_id = false,
username,
password,
params = {},
try_cache = true,
log_lvl = 0
}: {
api_cfg: any;
account_id: string;
null_account_id?: boolean;
username: string;
password: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
console.log(
`*** auth_ae_obj__username_password() *** account_id=${account_id} username=${username} password=${password}`
`*** auth_ae_obj__username_password() *** account_id=${account_id} username=${username}`
);
}
const endpoint = '/user/authenticate';
// Prepare API config with correct headers to override global guest settings
// WHY: Must set x-account-id explicitly so the backend scopes the username lookup
// to the correct account. Remove x-no-account-id which is only used during bootstrap.
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) {
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
if (null_account_id) {
params['null_account_id'] = true;
}
params['username'] = username; // Required
params['password'] = password; // Required
params['inc_jwt'] = true; // Request a JWT in the response
if (log_lvl > 1) {
console.log(`auth_ae_obj__username_password() - params:`, params);
}
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
ae_promises.auth__username_password = await api
.get_object({
.post_object({
api_cfg: use_api_cfg,
endpoint: endpoint,
params: params,
// data: {},
log_lvl: log_lvl
endpoint: '/v3/action/user/authenticate',
data: { username, password },
log_lvl
})
.then(async function (user_obj_get_result) {
if (user_obj_get_result) {
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
.then(function (result: any) {
return result ?? null;
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
console.log('auth_ae_obj__username_password failed:', error);
return null;
});
if (log_lvl) {
console.log(
'ae_promises.auth__username_password:',
ae_promises.auth__username_password
);
}
return ae_promises.auth__username_password;
}
// Updated 2025-04-04
// This function handles authentication using a User ID and a one-time auth key.
// Updated 2026-04-25 — migrated from GET /user/authenticate to POST /v3/action/user/authenticate
export async function auth_ae_obj__user_id_user_auth_key({
api_cfg,
account_id,
user_id,
user_auth_key,
params = {},
try_cache = true,
log_lvl = 0
}: {
api_cfg: any;
account_id: string;
user_id: string;
user_auth_key: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
@@ -447,61 +406,36 @@ export async function auth_ae_obj__user_id_user_auth_key({
);
}
const endpoint = '/user/authenticate';
// Prepare API config with correct headers to override global guest settings
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) {
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
params['user_id'] = user_id; // Required
params['auth_key'] = user_auth_key; // Required
params['inc_jwt'] = true; // Request a JWT in the response
if (log_lvl > 1) {
console.log(`auth_ae_obj__user_id_user_auth_key() - params:`, params);
}
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
ae_promises.auth__user_id_user_key = await api
.get_object({
.post_object({
api_cfg: use_api_cfg,
endpoint: endpoint,
params: params,
log_lvl: log_lvl
endpoint: '/v3/action/user/authenticate',
// WHY: valid_email=true marks the user's email as verified on successful magic-link auth
data: { user_id, auth_key: user_auth_key, valid_email: true },
log_lvl
})
.then(async function (user_obj_get_result) {
if (user_obj_get_result) {
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
.then(function (result: any) {
return result ?? null;
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
console.log('auth_ae_obj__user_id_user_auth_key failed:', error);
return null;
});
if (log_lvl) {
console.log(
'ae_promises.auth__user_id_user_key:',
ae_promises.auth__user_id_user_key
);
}
return ae_promises.auth__user_id_user_key;
}
// Send an email to the user with a new one time use authentication key.
// Updated 2025-04-08
// NOTE: This legacy endpoint is sensitive to extra query parameters and will 500 if account_id is passed in the URL.
// Updated 2026-04-25 — migrated from GET /user/{id}/email_auth_key_url to V3 action path
export async function send_email_auth_ae_obj__user_id({
api_cfg,
account_id,
user_id,
base_url,
key_param_name = 'user_key', // API defaults to 'auth_key'
params = {},
key_param_name = 'user_key',
log_lvl = 0
}: {
api_cfg: any;
@@ -509,7 +443,6 @@ export async function send_email_auth_ae_obj__user_id({
user_id: string;
base_url?: string;
key_param_name?: string;
params?: key_val;
log_lvl?: number;
}) {
if (log_lvl) {
@@ -518,47 +451,30 @@ export async function send_email_auth_ae_obj__user_id({
);
}
const email_auth_key_endpoint = `/user/${user_id}/email_auth_key_url`;
params = {
root_url: base_url,
key_param_name: key_param_name
};
// Prepare API config with correct headers
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) {
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
// WARNING: Do NOT add account_id to params here, as it causes a 500 error on the legacy backend.
}
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
ae_promises.auth_key__send_email = await api.get_object({
api_cfg: use_api_cfg,
endpoint: email_auth_key_endpoint,
params: params,
log_lvl: log_lvl
endpoint: `/v3/action/user/${user_id}/email_auth_key_url`,
params: { root_url: base_url, key_param_name },
log_lvl
});
return ae_promises.auth_key__send_email;
}
// Look up user based on email address provided
// Updated 2025-04-08
// Updated 2026-04-25 — migrated from GET /user/lookup_email to POST /v3/crud/user/search
export async function qry_ae_obj_li__user_email({
api_cfg,
account_id,
null_account_id = false,
email,
params = {},
try_cache = true,
log_lvl = 0
}: {
api_cfg: any;
account_id: string;
null_account_id?: boolean;
email: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (log_lvl) {
@@ -567,56 +483,39 @@ export async function qry_ae_obj_li__user_email({
);
}
const endpoint = '/user/lookup_email';
// Prepare API config with correct headers
const use_api_cfg = { ...api_cfg, headers: { ...api_cfg.headers } };
if (account_id) {
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
params['account_id'] = account_id;
}
use_api_cfg.headers['x-account-id'] = account_id;
delete use_api_cfg.headers['x-no-account-id'];
params['email'] = email; // Required
params['null_account_id'] = null_account_id || false;
ae_promises.qry__user_email = await api
.get_object({
const results = await api
.search_ae_obj({
api_cfg: use_api_cfg,
endpoint: endpoint,
params: params,
log_lvl: log_lvl
})
.then(async function (user_obj_get_result) {
if (user_obj_get_result) {
return user_obj_get_result;
} else {
console.log('No results returned.');
return null;
}
obj_type: 'user',
search_query: { and: [{ field: 'email', op: 'eq', value: email }] },
log_lvl
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
console.log('qry_ae_obj_li__user_email failed:', error);
return null;
});
return ae_promises.qry__user_email;
// Return the first match to preserve the same interface callers expect
// (the old /user/lookup_email endpoint returned a single user object)
return results?.[0] ?? null;
}
// Change user password
// Updated 2025-04-11
// Updated 2026-04-25 — migrated from PATCH /user/{id}/change_password to POST /v3/action
export async function auth_ae_obj__user_id_change_password({
api_cfg,
account_id,
user_id,
password,
params = {},
log_lvl = 0
}: {
api_cfg: any;
account_id: string;
user_id: string;
password: string;
params?: key_val;
log_lvl?: number;
}) {
if (log_lvl) {
@@ -625,27 +524,19 @@ export async function auth_ae_obj__user_id_change_password({
);
}
const endpoint = `/user/${user_id}/change_password`;
params['user_id'] = user_id; // Required
ae_promises.change_password__user_id = await api
.patch_object({
api_cfg: api_cfg,
endpoint: endpoint,
params: params,
data: { password: password },
log_lvl: log_lvl
.post_object({
api_cfg,
endpoint: `/v3/action/user/${user_id}/change_password`,
data: { new_password: password },
log_lvl
})
.then(async function (change_password_result) {
if (change_password_result) {
return change_password_result;
} else {
console.log('No results returned.');
return null;
}
.then(function (result: any) {
return result ?? null;
})
.catch(function (error: any) {
console.log('No results returned or failed.', error);
console.log('auth_ae_obj__user_id_change_password failed:', error);
return null;
});
return ae_promises.change_password__user_id;

View File

@@ -74,6 +74,9 @@ async function load_ae_obj_id__site_domain({
no_account_id = true;
// api_cfg.headers['x_account_id'] = 'nothing here';
}
// LEGACY BOOTSTRAP SPECIAL CASE: this helper is effectively a remove
// candidate once all site-domain lookups use the cache-first/bootstrap
// path in ae_core__site.ts.
no_account_id = true;
const params = {};

View File

@@ -28,6 +28,10 @@ export async function load_ae_obj_by_code__data_store({
save_idb?: boolean;
timeout?: number;
log_lvl?: number;
// TEMPORARY: this no-account fallback exists only until the backend
// can serve account-scoped defaults via JWT-backed access alone.
// Keep this path narrow and remove it when the backend no longer
// needs a transport-level scope drop for data_store.
}): Promise<any> {
if (log_lvl) {
console.log(`*** load_ae_obj_by_code__data_store() *** code=${code}`);

View File

@@ -1,6 +1,53 @@
import type { Dexie, Table } from 'dexie';
import { browser } from '$app/environment';
/**
* Checks IDB table content versions and clears any tables whose version has
* changed since the last check.
*
* Version numbers live in IDB_CONTENT_VERSIONS (store_versions.ts). State is
* tracked in localStorage (ae_idb_ver__{module}__{table}) so each table is
* cleared exactly once per version bump, not on every page load.
*
* Call once at module init in each db_*.ts, after the singleton is created.
* The clear is intentionally fire-and-forget — the SWR pattern repopulates
* from the API naturally after a cache miss.
*
* A null stored version (never tracked) is treated as outdated so existing
* installs with stale cached data are cleaned up on first run.
*/
export async function check_and_clear_idb_tables({
db_instance,
module_name,
table_versions,
log_lvl = 0
}: {
db_instance: Dexie;
module_name: string;
table_versions: Record<string, number>;
log_lvl?: number;
}): Promise<void> {
if (!browser) return;
for (const [table_name, expected_version] of Object.entries(table_versions)) {
const ls_key = `ae_idb_ver__${module_name}__${table_name}`;
const stored_raw = localStorage.getItem(ls_key);
const stored_version = stored_raw !== null ? parseInt(stored_raw, 10) : null;
if (stored_version === expected_version) continue;
try {
await db_instance.table(table_name).clear();
localStorage.setItem(ls_key, String(expected_version));
console.log(
`[IDB] "${module_name}.${table_name}" cleared — v${stored_version ?? 'new'} → v${expected_version}`
);
} catch (e) {
console.warn(`[IDB] Failed to clear "${module_name}.${table_name}":`, e);
}
}
}
/**
* Extracts the primary key from an object using a prioritized list of possible key names.
* @param obj The object to extract the ID from.

View File

@@ -0,0 +1,54 @@
/**
* src/lib/ae_core/core__idb_sort.ts
*
* Shared utility for computing tmp_sort_* fields stored in Dexie.
* All fields are designed for ascending .sortBy() — no .reverse() needed.
*
* Encoding rules:
* priority — inverted boolean: true→'0', false→'1' so priority=true sorts first (ASC)
* sort — zero-padded integer string so "00000010" < "00000020" (correct numeric order)
* all other fields — appended as-is; ISO 8601 datetimes already sort correctly
*
* Usage:
* const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({
* prefix: [obj.group ?? '0'], // fields before priority (optional)
* priority: obj.priority,
* sort: obj.sort,
* fields_1: [obj.start_datetime], // appended to base for tmp_sort_1
* fields_2: [obj.name], // appended after fields_1 for tmp_sort_2
* fields_3: [obj.updated_on], // appended after fields_2 for tmp_sort_3
* });
*/
export function build_tmp_sort({
prefix = [],
priority,
sort,
fields_1 = [],
fields_2 = [],
fields_3 = [],
pad_width = 8
}: {
prefix?: (string | null | undefined)[];
priority?: boolean | null;
sort?: number | string | null;
fields_1?: (string | null | undefined)[];
fields_2?: (string | null | undefined)[];
fields_3?: (string | null | undefined)[];
pad_width?: number;
}): { tmp_sort_1: string; tmp_sort_2: string; tmp_sort_3: string } {
const clean = (v: string | null | undefined): string => v ?? '';
const p = priority ? '0' : '1';
const s = String(Number(sort ?? 0)).padStart(pad_width, '0');
const parts_base = [...prefix.map(clean), p, s].join('_');
const parts_1 = fields_1.map(clean).filter(Boolean).join('_');
const parts_2 = fields_2.map(clean).filter(Boolean).join('_');
const parts_3 = fields_3.map(clean).filter(Boolean).join('_');
const tmp_sort_1 = [parts_base, parts_1].filter(Boolean).join('_');
const tmp_sort_2 = [tmp_sort_1, parts_2].filter(Boolean).join('_');
const tmp_sort_3 = [tmp_sort_2, parts_3].filter(Boolean).join('_');
return { tmp_sort_1, tmp_sort_2, tmp_sort_3 };
}

View File

@@ -1,75 +0,0 @@
export interface Site_Domain {
id: string;
// id_random: string;
site_id: string;
site_id_random?: string;
fqdn: string;
access_key?: null | string;
required_referrer?: null | string;
valid_for?: null | number; // In hours
enable: null | boolean;
hide?: null | boolean;
priority?: null | boolean;
sort?: null | number;
group?: null | string;
notes?: null | string;
created_on: Date;
updated_on?: null | Date;
}
import { api } from '$lib/api/api';
/**
* Fetches a site_domain object by its Fully Qualified Domain Name (FQDN).
*
* @param api_cfg - The API configuration object.
* @param fqdn - The FQDN of the site domain to fetch.
* @param timeout - The request timeout in milliseconds.
* @param log_lvl - The logging level.
* @returns The site domain object or null if not found.
*/
export async function load_ae_obj_by_fqdn__site_domain({
api_cfg,
fqdn,
timeout = 7000,
log_lvl = 0
}: {
api_cfg: any;
fqdn: string;
timeout?: number;
log_lvl?: number;
}): Promise<any> {
if (log_lvl) {
console.log(
`*** load_ae_obj_by_fqdn__site_domain() *** api.base_url=${api_cfg.base_url}, fqdn=${fqdn}, timeout=${timeout}`
);
}
const params = {};
try {
const site_domain_obj = await api.get_ae_obj_id_crud({
api_cfg: api_cfg,
no_account_id: true, // This seems to be a special case for this endpoint
obj_type: 'site_domain',
obj_id: fqdn, // NOTE: This is the FQDN, not the ID.
use_alt_table: true,
use_alt_base: true,
params: params,
timeout: timeout,
log_lvl: log_lvl
});
if (site_domain_obj) {
return site_domain_obj;
} else {
console.log('No results returned.');
return null;
}
} catch (error) {
console.log('No results returned or failed.', error);
return null;
}
}

View File

@@ -9,23 +9,23 @@ import { Modal } from 'flowbite-svelte';
import {
Bot,
BotMessageSquare,
Eye,
EyeOff,
Globe,
Copy,
Loader,
FileText,
Save,
FilePenLine,
RotateCcw,
Settings,
RefreshCcw,
Globe,
Copy
} from '@lucide/svelte';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { ae_loc } from '$lib/stores/ae_stores';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
interface Props {
// Core Props
content: string; // The text to summarize/analyze
summary: string; // The result (bindable)
content: string | null | undefined; // The text to summarize/analyze
summary: string | null | undefined; // The result (bindable)
// Configuration (Bindable for global settings persistence)
model?: string;
@@ -68,10 +68,21 @@ if (maxTokens === undefined) maxTokens = 512;
if (temperature === undefined) temperature = 0.7;
// Internal State
let ae_promises: any = $state(null);
let ae_promises = $state<Promise<unknown> | null>(null);
let show_modal = $state(false);
let active_tab: 'result' | 'settings' = $state('result');
let tmp_summary = $state('');
let show_api_token = $state(false);
const panel_class = 'space-y-4 rounded-xl border border-surface-500/20 bg-surface-500/5 p-4 shadow-sm';
const panel_title_class = 'flex items-center gap-2 border-b border-surface-500/20 pb-2 text-lg font-bold';
const tab_button_base_class = 'btn btn-sm border transition-all duration-200';
const tab_button_active_class = 'border-surface-200-800 bg-surface-200-800 text-surface-950-50 shadow-sm';
const tab_button_inactive_class = 'border-transparent bg-surface-50-900/60 text-surface-600-400 hover:border-surface-200-800 hover:bg-surface-100-900 hover:text-surface-950-50';
function tab_button_class(is_active: boolean): string {
return `${tab_button_base_class} ${is_active ? tab_button_active_class : tab_button_inactive_class}`;
}
async function generate_ai_result() {
if (!content) {
@@ -83,7 +94,9 @@ async function generate_ai_result() {
// If no token is provided, trigger a "Demo Mode" placeholder after a fake delay
if (!token || token === '') {
console.log('AE_AITools: No token provided. Entering Demo Mode.');
if (log_lvl > 0) {
console.log('AE_AITools: No token provided. Entering Demo Mode.');
}
ae_promises = new Promise((resolve) => {
setTimeout(() => {
tmp_summary = `### AI Summary (DEMO MODE)\n\nThis is a placeholder summary because no API token was provided in the settings. \n\n**Original Content Length:** ${content.length} characters.\n\n**System Prompt:** ${systemPrompt}\n\n**Model:** ${model}`;
@@ -121,10 +134,13 @@ async function generate_ai_result() {
tmp_summary = result;
show_modal = true;
});
} catch (err: any) {
console.error('AE_AITools: AI Error:', err);
} catch (err: unknown) {
if (log_lvl > 0) {
console.error('AE_AITools: AI Error:', err);
}
const err_msg = err instanceof Error ? err.message : String(err);
// Even on error, show the modal with the error message so the UI can be inspected
tmp_summary = `### AI Error\n\nFailed to connect to the AI service.\n\n**Error:** ${err.message}\n\nCheck your Settings tab for Base URL and Token configuration.`;
tmp_summary = `### AI Error\n\nFailed to connect to the AI service.\n\n**Error:** ${err_msg}\n\nCheck your Settings tab for Base URL and Token configuration.`;
show_modal = true;
ae_promises = Promise.resolve();
}
@@ -141,15 +157,23 @@ function handle_save() {
<!-- Trigger Button -->
<button
type="button"
onclick={generate_ai_result}
onclick={() => {
if (summary) {
tmp_summary = summary;
active_tab = 'result';
show_modal = true;
} else {
generate_ai_result();
}
}}
class={buttonClass}
title="Generate AI summary/analysis">
title={summary ? 'View existing AI summary' : 'Generate AI summary/analysis'}>
{#await ae_promises}
<Loader class="mr-1 inline-block animate-spin" size="1.2em" />
<span class="text-sm">Processing...</span>
{:then}
<BotMessageSquare class="mr-1 inline-block" size="1.2em" />
<span class="text-sm">Summarize</span>
<span class="text-sm hidden">Summarize</span>
{:catch}
<span class="text-sm text-red-500">Error</span>
{/await}
@@ -162,9 +186,10 @@ function handle_save() {
active_tab = 'settings';
show_modal = true;
}}
class="btn btn-sm variant-soft-surface shadow-md"
class="btn btn-sm preset-tonal-surface shadow-md"
title="AI Settings">
<Settings size="1.2em" />
<span class="text-sm hidden">Settings</span>
</button>
<!-- Unified AI Modal -->
@@ -173,21 +198,17 @@ function handle_save() {
title="Aether AI Assistant"
bind:open={show_modal}
size="lg"
class="bg-white dark:bg-gray-800">
<div class="space-y-4 p-2">
class="relative mx-auto flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] w-full flex-col rounded-xl border border-surface-200-800 bg-surface-50-900 text-surface-950-50 shadow-xl">
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-3">
<!-- Tab Navigation -->
<div class="border-surface-500/20 flex gap-1 border-b pb-2">
<div class="bg-surface-500/10 sticky top-0 z-10 mx-auto flex max-w-fit justify-center gap-1 rounded-lg p-1 backdrop-blur-sm">
<button
class="btn btn-sm {active_tab === 'result'
? 'variant-filled-primary'
: 'variant-soft-surface'}"
class={tab_button_class(active_tab === 'result')}
onclick={() => (active_tab = 'result')}>
<Bot size="1.1em" class="mr-1" /> Result
</button>
<button
class="btn btn-sm {active_tab === 'settings'
? 'variant-filled-secondary'
: 'variant-soft-surface'}"
class={tab_button_class(active_tab === 'settings')}
onclick={() => (active_tab = 'settings')}>
<Settings size="1.1em" class="mr-1" /> Settings
</button>
@@ -197,12 +218,12 @@ function handle_save() {
<div class="animate-in fade-in space-y-4 duration-200">
<div class="flex justify-start gap-2">
<button
class="btn btn-sm variant-filled-success"
class="btn btn-sm preset-filled-success"
onclick={handle_save}>
<Save size="1.1em" class="mr-1" /> Save Result
</button>
<button
class="btn btn-sm variant-ghost-primary"
class="btn btn-sm preset-tonal-primary"
onclick={generate_ai_result}>
<RotateCcw size="1.1em" class="mr-1" /> Re-run
</button>
@@ -219,15 +240,14 @@ function handle_save() {
<div
class="animate-in slide-in-from-left-4 space-y-6 duration-200">
<!-- Connection Settings -->
<div class="space-y-4">
<h3
class="text-surface-500 flex items-center gap-2 text-sm font-bold tracking-widest uppercase">
<section class={panel_class}>
<h3 class={panel_title_class}>
<Globe size="1.1em" /> API Connection
</h3>
{#if onSyncConfig}
<button
class="btn btn-sm variant-soft-primary"
class="btn btn-sm preset-tonal-primary"
onclick={onSyncConfig}>
<Copy size="1.1em" class="mr-1" /> Sync Global
Defaults
@@ -239,6 +259,10 @@ function handle_save() {
<span>Base URL</span>
<input
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
bind:value={baseUrl}
class="input input-sm" />
</label>
@@ -246,24 +270,54 @@ function handle_save() {
<span>Model</span>
<input
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="ai_model"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={model}
class="input input-sm" />
</label>
</div>
<label class="label">
<span>API Token</span>
<input
type="password"
bind:value={token}
class="input input-sm font-mono" />
<div class="flex gap-2">
<input
type={show_api_token ? 'text' : 'password'}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="ai_api_token"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={token}
class="input input-sm font-mono" />
<button
type="button"
class="btn btn-sm preset-tonal-surface"
onclick={() =>
(show_api_token = !show_api_token)}
title={show_api_token
? 'Hide API Token'
: 'Show API Token'}>
{#if show_api_token}
<EyeOff size="1.1em" />
{:else}
<Eye size="1.1em" />
{/if}
</button>
</div>
</label>
</div>
</section>
<!-- Model Parameters -->
<div
class="border-surface-500/10 space-y-4 border-t pt-4">
<h3
class="text-surface-500 flex items-center gap-2 text-sm font-bold tracking-widest uppercase">
<section class={panel_class}>
<h3 class={panel_title_class}>
<FilePenLine size="1.1em" /> Inference Parameters
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -289,10 +343,10 @@ function handle_save() {
<span>System Prompt</span>
<textarea
bind:value={systemPrompt}
class="textarea h-24 font-mono text-xs"
class="textarea h-24 font-mono text-xs bg-surface-50-900"
></textarea>
</label>
</div>
</section>
</div>
{/if}
</div>

View File

@@ -6,8 +6,7 @@
*/
import {
Siren,
MessageSquareWarning,
Fingerprint,
FingerprintPattern,
Globe,
BookHeart,
BriefcaseBusiness,
@@ -15,10 +14,11 @@ import {
Settings
} from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import type { ae_JournalEntryDraft } from '$lib/types/ae_types';
interface Props {
// The object containing the flags (bindable)
obj: any;
obj: ae_JournalEntryDraft;
// Visibility configuration (optional overrides)
show_labels?: boolean;
@@ -49,9 +49,38 @@ let {
container_class = 'flex flex-row flex-wrap gap-1 items-center justify-evenly py-2 border-y border-surface-500/10'
}: Props = $props();
function handle_toggle(prop: string) {
obj[prop] = !obj[prop];
if (onToggle) onToggle(prop, obj[prop]);
function emit_toggle(prop: string, value: boolean) {
if (onToggle) onToggle(prop, value);
}
function toggle_alert() {
obj.alert = !obj.alert;
emit_toggle('alert', !!obj.alert);
}
function toggle_private() {
obj.private = !obj.private;
emit_toggle('private', !!obj.private);
}
function toggle_public() {
obj.public = !obj.public;
emit_toggle('public', !!obj.public);
}
function toggle_personal() {
obj.personal = !obj.personal;
emit_toggle('personal', !!obj.personal);
}
function toggle_professional() {
obj.professional = !obj.professional;
emit_toggle('professional', !!obj.professional);
}
function toggle_template() {
obj.template = !obj.template;
emit_toggle('template', !!obj.template);
}
</script>
@@ -63,81 +92,69 @@ function handle_toggle(prop: string) {
</span>
{/if}
<!-- Alert Status -->
{#if !hide_alert}
<button
type="button"
onclick={() => handle_toggle('alert')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Alert Status">
<Siren
size="1.2em"
class={obj?.alert ? 'text-error-500' : 'opacity-40'} />
onclick={toggle_alert}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle alert status">
<Siren size="1.2em" class={obj?.alert ? 'text-error-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Alert</span>
</button>
{/if}
<!-- Private / E2EE -->
{#if !hide_private}
<button
type="button"
onclick={() => handle_toggle('private')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Private/Encrypted">
<Fingerprint
size="1.2em"
class={obj?.private ? 'text-success-500' : 'opacity-40'} />
onclick={toggle_private}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle private or encrypted visibility">
<FingerprintPattern size="1.2em" class={obj?.private ? 'text-success-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Private or Encrypt</span>
</button>
{/if}
<!-- Public Visibility -->
{#if !hide_public}
<button
type="button"
onclick={() => handle_toggle('public')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Public Visibility">
<Globe
size="1.2em"
class={obj?.public ? 'text-success-500' : 'opacity-40'} />
onclick={toggle_public}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle public visibility">
<Globe size="1.2em" class={obj?.public ? 'text-success-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Public</span>
</button>
{/if}
<!-- Personal Scope -->
{#if !hide_personal}
<button
type="button"
onclick={() => handle_toggle('personal')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Personal Scope">
<BookHeart
size="1.2em"
class={obj?.personal ? 'text-success-500' : 'opacity-40'} />
onclick={toggle_personal}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle personal scope">
<BookHeart size="1.2em" class={obj?.personal ? 'text-success-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Personal</span>
</button>
{/if}
<!-- Professional Scope -->
{#if !hide_professional}
<button
type="button"
onclick={() => handle_toggle('professional')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Professional Scope">
<BriefcaseBusiness
size="1.2em"
class={obj?.professional ? 'text-success-500' : 'opacity-40'} />
onclick={toggle_professional}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle professional scope">
<BriefcaseBusiness size="1.2em" class={obj?.professional ? 'text-success-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Professional</span>
</button>
{/if}
<!-- Template Status -->
{#if !hide_template && $ae_loc.edit_mode}
<button
type="button"
onclick={() => handle_toggle('template')}
class="btn-icon btn-icon-sm preset-tonal-secondary hover:preset-filled-secondary-500 transition"
title="Toggle Template Mode">
<NotepadTextDashed
size="1.2em"
class={obj?.template ? 'text-success-500' : 'opacity-40'} />
onclick={toggle_template}
class="btn btn-sm flex items-center gap-2 px-3 transition preset-tonal-secondary hover:preset-filled-secondary-500"
title="Toggle template mode">
<NotepadTextDashed size="1.2em" class={obj?.template ? 'text-success-500' : 'opacity-40'} />
<span class="whitespace-nowrap text-[10px] font-bold uppercase tracking-wider">Template</span>
</button>
{/if}
</div>

View File

@@ -787,7 +787,7 @@ export const properties_to_save = [
'event_id',
'code',
'account_id',
'account_id_random',
// 'account_id_random',
'conference',
'type',
'name',

View File

@@ -269,6 +269,50 @@ async function _refresh_file_li_background({
properties_to_save,
log_lvl
});
// Prune stale Dexie records that no longer exist on the server.
// WHY: bulkPut only upserts — it never removes records deleted on
// the server. Without this, deleted files remain visible in the UI
// indefinitely (the liveQuery sees them in Dexie forever).
//
// Scope guard: only delete records that WOULD have been included in
// this query. Records outside the query scope (e.g. a hidden file
// when hidden='not_hidden') are intentionally left in Dexie so
// that a later 'all' query can still find them.
// We prune on any valid API response, including an empty list —
// an empty array from the API is authoritative ("no files here").
const returned_ids = new Set(
processed.map((f: any) => f.id).filter(Boolean)
);
const dexie_all = await db_events.file
.where('for_id')
.equals(for_obj_id)
.toArray();
const stale_ids = dexie_all
.filter((f) => {
if (!f.id || returned_ids.has(f.id)) return false;
// Would this record have appeared in our query?
const is_enabled = !!f.enable;
const is_hidden = !!f.hide;
const within_enabled_scope =
enabled === 'all' ||
(enabled === 'enabled' && is_enabled) ||
(enabled === 'not_enabled' && !is_enabled);
const within_hidden_scope =
hidden === 'all' ||
(hidden === 'not_hidden' && !is_hidden) ||
(hidden === 'hidden' && is_hidden);
return within_enabled_scope && within_hidden_scope;
})
.map((f) => f.id as string);
if (stale_ids.length > 0) {
if (log_lvl)
console.log(
`🗑️ [DEBUG] Pruning ${stale_ids.length} stale file records from Dexie`,
stale_ids
);
await db_events.file.bulkDelete(stale_ids);
}
}
return processed;
}
@@ -315,7 +359,7 @@ export async function create_event_file_obj_from_hosted_file_async({
});
if (return_obj) return result;
return result?.event_file_id || result?.id || result?.event_file_id_random;
return result?.event_file_id || result?.id;
}
export async function delete_ae_obj_id__event_file({
@@ -483,15 +527,11 @@ export const qry__event_file = search__event_file;
export const properties_to_save = [
'id',
'event_file_id',
// 'event_file_id_random', // DO NOT UNCOMMENT
'hosted_file_id',
// 'hosted_file_id_random', // DO NOT UNCOMMENT
'hash_sha256',
'for_type',
'for_id',
// 'for_id_random', // DO NOT UNCOMMENT
'event_id',
// 'event_id_random', // DO NOT UNCOMMENT
'event_session_id',
'event_presentation_id',
'event_presenter_id',
@@ -554,22 +594,9 @@ async function _process_generic_props<T extends Record<string, any>>({
const processed_obj_li: T[] = [];
for (const original_obj of obj_li) {
let processed_obj = { ...original_obj };
for (const key in processed_obj) {
if (key.endsWith('_random')) {
const newKey = key.slice(0, -7);
// ONLY overwrite if the random variant has a valid value
if (
processed_obj[key] !== null &&
processed_obj[key] !== undefined &&
processed_obj[key] !== ''
) {
(processed_obj as any)[newKey] = processed_obj[key];
}
}
}
const random_id_key = `${obj_type}_id_random`;
if (processed_obj[random_id_key])
(processed_obj as any).id = processed_obj[random_id_key];
const base_id_key = `${obj_type}_id`;
if (processed_obj[base_id_key])
(processed_obj as any).id = processed_obj[base_id_key];
const group = processed_obj.group ?? '0';
const priority = processed_obj.priority ? 1 : 0;
const sort = processed_obj.sort ?? '0';

View File

@@ -357,7 +357,7 @@ async function _handle_nested_loads(
for_obj_type: 'event_location',
for_obj_id: current_location_id,
enabled: 'all',
limit: 25,
hidden: 'all',
log_lvl
}).then((res) => (location_obj.event_file_li = res))
);

View File

@@ -2,6 +2,7 @@ import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
import { db_events } from '$lib/ae_events/db_events';
import type { ae_EventPresentation } from '$lib/types/ae_types';
@@ -181,6 +182,8 @@ async function _handle_nested_loads(
for_obj_type: 'event_presentation',
for_obj_id: current_presentation_id,
enabled,
// WHY: include hidden files so Manage Files UI can show/unhide them.
hidden: 'all',
limit: 25,
try_cache,
log_lvl
@@ -678,6 +681,18 @@ export async function process_ae_obj__event_presentation_props({
if (obj.event_session_id_random)
obj.event_session_id = obj.event_session_id_random;
if (obj.event_id_random) obj.event_id = obj.event_id_random;
// Override generic tmp_sort_* with presentation-specific encoding via
// build_tmp_sort. Order: priority DESC → sort ASC → start_datetime ASC → code ASC → name ASC
const { tmp_sort_1, tmp_sort_2 } = build_tmp_sort({
prefix: [obj.group ?? '0'],
priority: obj.priority,
sort: obj.sort,
fields_1: [obj.start_datetime, obj.code],
fields_2: [obj.name]
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
return obj;
}
});

View File

@@ -102,6 +102,8 @@ async function _refresh_presenter_id_background({
for_obj_type: 'event_presenter',
for_obj_id: event_presenter_id,
enabled: 'all',
// WHY: include hidden files so Manage Files UI can show/unhide them.
hidden: 'all',
limit: 25,
try_cache: false,
log_lvl
@@ -284,6 +286,8 @@ async function _refresh_presenter_li_background({
for_obj_type: 'event_presenter',
for_obj_id: p.id,
enabled: 'all',
// WHY: include hidden files so Manage Files UI can show/unhide them.
hidden: 'all',
limit: 25,
try_cache: false,
log_lvl: 0

View File

@@ -278,6 +278,10 @@ async function _handle_nested_loads(
for_obj_type: 'event_session',
for_obj_id: current_session_id,
enabled,
// WHY: must include hidden files so the Manage Files UI can show and unhide them.
// The default 'not_hidden' was causing hidden files to never reach Dexie,
// making them invisible in the manage list until a manual Refresh.
hidden: 'all',
limit: 15,
try_cache,
log_lvl
@@ -662,6 +666,7 @@ export async function search__event_session({
event_id,
fulltext_search_qry_str = '',
ft_presenter_search_qry_str = '',
ft_presentation_search_qry_str = '',
like_search_qry_str = '',
like_presentation_search_qry_str = '',
like_presenter_search_qry_str = '',
@@ -684,6 +689,7 @@ export async function search__event_session({
event_id: string;
fulltext_search_qry_str?: string;
ft_presenter_search_qry_str?: string | null;
ft_presentation_search_qry_str?: string | null;
like_search_qry_str?: string;
like_presentation_search_qry_str?: string;
like_presenter_search_qry_str?: string;
@@ -706,26 +712,32 @@ export async function search__event_session({
q: '',
and: [{ field: 'event_id', op: 'eq', value: event_id }]
};
if (fulltext_search_qry_str || ft_presenter_search_qry_str) {
if (fulltext_search_qry_str || ft_presenter_search_qry_str || ft_presentation_search_qry_str) {
const ft: any = {};
if (fulltext_search_qry_str && fulltext_search_qry_str.length > 2)
ft['default_qry_str'] = fulltext_search_qry_str;
if (
ft_presenter_search_qry_str &&
ft_presenter_search_qry_str.length > 2
)
) {
ft['event_presenter_li_qry_str'] = ft_presenter_search_qry_str;
// These fields only exist in v_event_session_w_file_count (alt view)
view = 'alt';
}
if (
ft_presentation_search_qry_str &&
ft_presentation_search_qry_str.length > 2
) {
ft['event_presentation_li_qry_str'] = ft_presentation_search_qry_str;
// These fields only exist in v_event_session_w_file_count (alt view)
view = 'alt';
}
if (Object.keys(ft).length) search_query.params = { ft_qry: ft };
}
if (enabled === 'enabled')
search_query.and.push({ field: 'enable', op: 'eq', value: 1 });
else if (enabled === 'not_enabled')
search_query.and.push({ field: 'enable', op: 'eq', value: 0 });
if (hidden === 'hidden')
search_query.and.push({ field: 'hide', op: 'eq', value: 1 });
else if (hidden === 'not_hidden')
search_query.and.push({ field: 'hide', op: 'eq', value: 0 });
if (location_name) {
search_query.and.push({
field: 'event_location_name',
@@ -757,6 +769,7 @@ export async function search__event_session({
view,
limit,
offset,
hidden,
log_lvl
});
@@ -832,12 +845,10 @@ export async function email_sign_in__event_session({
export const properties_to_save = [
'id',
'event_session_id',
'event_session_id_random',
'external_id',
'code',
'for_type',
'for_id',
'for_id_random',
'type_code',
'event_id',
'event_location_id',
@@ -876,7 +887,9 @@ export const properties_to_save = [
'event_name',
'event_location_code',
'event_location_name',
'event_presentation_li'
'event_presentation_li',
'event_presentation_li_qry_str',
'event_presenter_li_qry_str'
];
async function _process_generic_props<T extends Record<string, any>>({
@@ -894,18 +907,8 @@ async function _process_generic_props<T extends Record<string, any>>({
const processed_obj_li: T[] = [];
for (const original_obj of obj_li) {
let processed_obj = { ...original_obj };
for (const key in processed_obj) {
if (key.endsWith('_random')) {
const newKey = key.slice(0, -7);
(processed_obj as any)[newKey] = processed_obj[key];
}
}
const randomIdKey = `${obj_type}_id_random`;
const baseIdKey = `${obj_type}_id`;
if (processed_obj[randomIdKey]) {
(processed_obj as any).id = processed_obj[randomIdKey];
(processed_obj as any)[baseIdKey] = processed_obj[randomIdKey];
} else if (processed_obj[baseIdKey])
if (processed_obj[baseIdKey])
(processed_obj as any).id = processed_obj[baseIdKey];
const group = processed_obj.group ?? '0';

View File

@@ -0,0 +1,400 @@
/**
* ae_launcher__default_launch_profiles.ts
*
* Built-in launch profiles for the Aether Events Launcher — the Svelte-side
* replacement for the legacy OSIT MasterKey Swift app.
*
* These are the last-resort defaults. Override priority (high → low):
* 1. event_file.cfg_json.display_override — per-file, display_mode only
* 2. event_device.data_json.launch_profiles[profile] — per-profile override, per device (API)
* 3. $events_loc.launcher.launch_profiles[profile] — local persistent override
* 4. DEFAULT_LAUNCH_PROFILES[profile/alias] — canonical built-ins + aliases
* 5. DEFAULT_LAUNCH_PROFILES['default'] — catch-all
*
* Keys are lowercase file extensions without the dot: "pptx", "key", "pdf", etc.
* The special key "default" catches any unrecognised extension.
*
* post_script formats:
* - Plain string → run as AppleScript via run_osascript() (macOS only)
* - "shell:..." prefix → run as shell command via run_cmd()
*
* Reserved for future use:
* - speed_factor: number — delay multiplier for slower machines (1.0 = normal)
*
* Special pseudo-extension:
* - url — web-based presentations. Handled by the launcher URL branch rather
* than a cache-to-temp open flow.
*/
export interface LaunchProfile {
/** Human-readable label for status messages */
app: string;
/** Display layout to set before opening. 'extend' only applied if external display found. */
display_mode: 'extend' | 'mirror' | 'none';
/**
* Shell command to open the file. {{path}} is replaced with the resolved temp path.
* If omitted, falls back to open_local_file_v2(path) — OS default handler.
*/
open_cmd?: string;
/**
* Script to run after the file opens and post_delay_ms has elapsed.
* Plain string → AppleScript (macOS). "shell:" prefix → shell command.
*/
post_script?: string;
/**
* Milliseconds to wait after open_cmd before running post_script.
* Default: 2000. Can be overridden per profile via launch_profiles[profile].post_delay_ms.
*/
post_delay_ms?: number;
// --- Reserved for future use — not yet implemented ---
// speed_factor?: number;
// url?: string;
}
/**
* macOS VLC profile — uses direct binary path for max reliability.
* Bypasses `open -a` argument-handling quirks that could lose file path or re-use existing process.
*
* WHY nohup + &:
* run_cmd uses exec() which blocks until the child process exits (or the 30s timeout fires).
* The direct VLC binary forks a GUI process then exits — exec returns early and the code
* proceeds to the post_script. The old post_script polled for VLC focus (up to 10s) then
* sent Cmd+F, which was firing exactly 1015 seconds into playback and stopping the video.
* nohup + & detaches VLC immediately so exec returns in ~0ms, decoupling run_cmd from
* VLC's lifecycle entirely.
*
* WHY --fullscreen:
* Starting VLC fullscreen via flag avoids the need to send Cmd+F via AppleScript. The old
* keystroke approach was the proximate cause of the video stopping — Cmd+F may have hit the
* wrong VLC window, triggered a menu action, or paused playback during the fullscreen
* transition. Using the flag is simpler and more reliable.
*
* WHY > /dev/null 2>&1:
* VLC logs verbosely to stdout/stderr. exec() buffers output (1MB default). Without
* redirection the buffer could overflow and kill VLC mid-playback.
*/
function make_vlc_mirror_mac_profile(): LaunchProfile {
return {
app: 'VLC (macOS)',
display_mode: 'mirror',
open_cmd: 'nohup /Applications/VLC.app/Contents/MacOS/VLC --no-play-and-exit --play-and-pause --fullscreen "{{path}}" > /dev/null 2>&1 &',
post_delay_ms: 3000,
// Activate VLC after it has had time to open. Fullscreen is already set by the flag
// above — this just ensures VLC is the frontmost app and the presenter sees it.
post_script: `tell application "VLC"
activate
end tell`
};
}
/**
* Linux VLC profile — uses shell command for compatibility.
*/
function make_vlc_mirror_linux_profile(): LaunchProfile {
return {
app: 'VLC (Linux)',
display_mode: 'mirror',
// shell: prefix runs as bash command. Same flags as macOS: `--no-play-and-exit` keeps window open, `--play-and-pause` holds final frame.
open_cmd: 'shell:vlc --no-play-and-exit --play-and-pause "{{path}}"',
post_delay_ms: 1000
// No post_script on Linux — VLC opens fullscreen by default, no need to send F.
};
}
const POWERPOINT_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'Microsoft PowerPoint',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft PowerPoint" "{{path}}"',
post_delay_ms: 1000,
post_script: `repeat 20 times
tell application "Microsoft PowerPoint"
activate
end tell
delay 0.5
tell application "System Events"
if frontmost of process "Microsoft PowerPoint" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "Microsoft PowerPoint"
keystroke return using command down
end tell
end tell`
};
const KEYNOTE_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'Keynote',
display_mode: 'extend',
open_cmd: 'open -a "Keynote" "{{path}}"',
post_delay_ms: 1000,
// Keynote uses `start (front document)` which requires the document to actually be loaded —
// polling frontmost is not enough here. Poll document count instead.
post_script: `tell application "Keynote"
activate
end tell
repeat 20 times
delay 0.5
tell application "Keynote"
if (count of documents) > 0 then exit repeat
end tell
end repeat
delay 0.3
tell application "Keynote"
start (front document)
end tell`
};
const LIBREOFFICE_MAC_EXTEND_PROFILE: LaunchProfile = {
app: 'LibreOffice',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 1000,
post_script: `repeat 20 times
tell application "LibreOffice"
activate
end tell
delay 0.5
tell application "System Events"
if frontmost of process "soffice" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "soffice"
key code 96
end tell
end tell`
};
const ACROBAT_MAC_MIRROR_PROFILE: LaunchProfile = {
app: 'Adobe Acrobat Reader DC',
display_mode: 'mirror',
open_cmd: 'open -a "Adobe Acrobat Reader DC" "{{path}}"',
post_delay_ms: 1000,
post_script: `repeat 20 times
tell application "Adobe Acrobat Reader DC"
activate
end tell
delay 0.5
tell application "System Events"
if frontmost of process "AdobeReader" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "AdobeReader"
keystroke "l" using command down
end tell
end tell`
};
const VLC_MIRROR_MAC_PROFILE: LaunchProfile = make_vlc_mirror_mac_profile();
const VLC_MIRROR_LINUX_PROFILE: LaunchProfile = make_vlc_mirror_linux_profile();
const POWERPOINT_WIN_EXTEND_PROFILE: LaunchProfile = {
app: 'Microsoft Office PowerPoint (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft Office PowerPoint" "{{path}}"',
post_delay_ms: 1500,
post_script: `tell application "Microsoft Office PowerPoint"
activate
end tell
repeat 15 times
delay 0.5
tell application "System Events"
if frontmost of process "Microsoft Office PowerPoint" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
key code 96
end tell`
};
const LIBREOFFICE_WIN_EXTEND_PROFILE: LaunchProfile = {
app: 'LibreOffice (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 1500,
post_script: `repeat 20 times
tell application "LibreOffice"
activate
end tell
delay 0.5
tell application "System Events"
if frontmost of process "soffice" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
tell process "soffice"
key code 96
end tell
end tell`
};
const ACROBAT_WIN_MIRROR_PROFILE: LaunchProfile = {
app: 'Acrobat Reader (Windows)',
display_mode: 'mirror',
open_cmd: 'open -a "Acrobat Reader Windows" "{{path}}"',
post_delay_ms: 1500,
post_script: `repeat 20 times
tell application "Acrobat Reader Windows"
activate
end tell
delay 0.5
tell application "System Events"
if frontmost of process "Acrobat Reader Windows" is true then exit repeat
end tell
end repeat
delay 0.3
tell application "System Events"
key code 108 using control down
end tell`
};
const URL_WEB_PROFILE: LaunchProfile = {
app: 'Chrome',
display_mode: 'extend',
// No open_cmd or post_script — URL branch in handle_open_file() handles this
};
const DEFAULT_OS_PROFILE: LaunchProfile = {
app: 'OS Default',
display_mode: 'none',
// No open_cmd — execution falls through to open_local_file_v2(path)
// No post_script
};
type DefaultLaunchProfileDefinition = {
name: string;
aliases: string[];
profile: LaunchProfile;
};
export const DEFAULT_LAUNCH_PROFILE_DEFS: DefaultLaunchProfileDefinition[] = [
{
name: 'powerpoint_mac_extend',
aliases: ['pptx', 'ppt'],
profile: POWERPOINT_MAC_EXTEND_PROFILE
},
{
name: 'keynote_mac_extend',
aliases: ['key'],
profile: KEYNOTE_MAC_EXTEND_PROFILE
},
{
name: 'libreoffice_mac_extend',
aliases: ['odp'],
profile: LIBREOFFICE_MAC_EXTEND_PROFILE
},
{
name: 'acrobat_mac_mirror',
aliases: ['pdf'],
profile: ACROBAT_MAC_MIRROR_PROFILE
},
{
name: 'vlc_mirror_mac',
aliases: [],
profile: VLC_MIRROR_MAC_PROFILE
},
{
name: 'vlc_mirror_linux',
aliases: [],
profile: VLC_MIRROR_LINUX_PROFILE
},
{
name: 'vlc_mirror',
aliases: ['mp4', 'mkv', 'mov', 'mpeg', 'avi', 'flv', 'ogg', 'ogv', 'mp3', 'm4v', 'm4a', 'webm', 'wmv', 'wav', 'aac', 'flac'],
profile: VLC_MIRROR_MAC_PROFILE // Default to macOS (primary deployment platform)
},
{
name: 'powerpoint_win_extend',
aliases: ['pptxwin', 'pptwin'],
profile: POWERPOINT_WIN_EXTEND_PROFILE
},
{
name: 'libreoffice_win_extend',
aliases: ['odpwin'],
profile: LIBREOFFICE_WIN_EXTEND_PROFILE
},
{
name: 'acrobat_win_mirror',
aliases: ['pdfwin'],
profile: ACROBAT_WIN_MIRROR_PROFILE
},
{
name: 'url_web',
aliases: ['url'],
profile: URL_WEB_PROFILE
},
{
name: 'os_default',
aliases: ['default'],
profile: DEFAULT_OS_PROFILE
}
];
export const DEFAULT_LAUNCH_PROFILE_LIBRARY: Record<string, LaunchProfile> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.map(({ name, profile }) => [name, profile])
);
export const DEFAULT_LAUNCH_PROFILE_ALIASES: Record<string, string> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.flatMap(({ name, aliases }) =>
aliases.map((alias) => [alias, name])
)
);
export const DEFAULT_LAUNCH_PROFILES: Record<string, LaunchProfile> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.flatMap(({ name, aliases, profile }) => [
[name, { ...profile }],
...aliases.map((alias) => [alias, { ...profile }])
])
);
/**
* Returns a shallow copy of the built-in profile for the given extension,
* with a display_override applied if provided.
*
* Falls back to 'default' if no specific profile exists.
*/
export function resolve_launch_profile(
extension: string,
display_override?: 'extend' | 'mirror' | 'none' | null,
device_profiles?: Record<string, Partial<LaunchProfile>> | null,
local_profiles?: Record<string, Partial<LaunchProfile>> | null
): LaunchProfile {
const ext = (extension || '').toLowerCase().replace(/^\./, '');
const canonical_profile_name = DEFAULT_LAUNCH_PROFILE_ALIASES[ext] ?? ext;
const built_in_profile =
DEFAULT_LAUNCH_PROFILE_LIBRARY[canonical_profile_name] ??
DEFAULT_LAUNCH_PROFILE_LIBRARY.os_default;
const local_profile =
local_profiles?.[canonical_profile_name] ??
local_profiles?.[ext] ??
local_profiles?.['default'] ??
null;
const device_profile =
device_profiles?.[canonical_profile_name] ??
device_profiles?.[ext] ??
device_profiles?.['default'] ??
null;
const profile = {
...built_in_profile,
...(local_profile ?? {}),
...(device_profile ?? {})
};
// Per-file display override wins over everything
if (display_override) {
profile.display_mode = display_override;
}
return profile;
}

View File

@@ -0,0 +1,81 @@
/* =============================================================================
Badge Layout: Epson ColorWorks — Fanfold 4" × 6" (Single-sided)
layout code: badge_4x6_fanfold
Badge stock: 4in wide × 6in per label, single-sided continuous fanfold
Used for: Axonius Adapt 2026 (June 2026)
Physical notes (measured 2026-05-15):
Overall: 4.0in × 6.0in (portrait)
Lanyard hole: 5/8in wide × 1/8in tall, centered horizontally,
1/4in from top edge, 3/8in from each side edge.
Keep decorative header content below the hole zone (~3/8in from top).
Print behavior:
Single-sided only. Set duplex=0 on the template — the badge_back section
will not render at all. @page size (4in × 6in) is injected dynamically
by print/+page.svelte <svelte:head> based on the layout field.
CSS scope:
All rules scoped under [data-layout="badge_4x6_fanfold"] to avoid conflicts
with other layouts compiled into the same bundle. These override the
Tailwind utility classes (w-[4in], min-h-[6.0in], etc.) hardcoded on the
badge sections — attribute + class selectors win over single class selectors.
============================================================================= */
/* --- Badge front --- */
[data-layout='badge_4x6_fanfold'] .badge_front {
min-width: 4in;
width: 4in;
min-height: 6in;
max-height: 6in;
/* debug */
/* outline: thick solid orange; */
}
/*
* Header image zone: 624×232px at full 4in badge width → natural height ≈ 1.49in.
* Override Tailwind's max-h-[1.00in] to avoid cropping the bottom of the image.
* min-h-[.50in] from the component HTML is fine; leave it in place.
*/
[data-layout='badge_4x6_fanfold'] .badge_header {
max-height: 1.5in;
}
/*
* Body area: 6in total 1.5in header 0.5in footer = 4.0in for name/title/affiliations.
*
* margin-top: 0 overrides the component-level mt-54 (≈2.25in). That margin was added
* for the PVC badge layout (badge_3.5x5.5_pvc) where a full-bleed background_image_path
* was used — the body needed to start below the background image's logo zone. For
* fanfold badges with a standalone header_path image and no background, mt-54 creates
* a large blank gap between the header and the attendee name.
*/
[data-layout='badge_4x6_fanfold'] .badge_body {
margin-top: 0;
max-height: 4.0in;
}
/* Outer wrapper: strip the default padding/gap so the outline hugs the badge.
badge_front above supplies the exact 4×6in card size. */
[data-layout='badge_4x6_fanfold'].event_badge_wrapper {
padding: 0;
gap: 0;
min-height: 0;
min-width: 4in;
width: 4in;
max-width: 4in;
}
@media print {
[data-layout='badge_4x6_fanfold'].event_badge_wrapper {
width: 4in !important;
height: 6in !important;
max-width: 4in !important;
max-height: 6in !important;
/* overflow: visible so any intentional bleed element is not clipped by the
wrapper — the Epson driver clips at the physical label edge. */
overflow: visible !important;
}
}

View File

@@ -809,6 +809,9 @@ export interface Session {
// A key value list of the presentations
event_presentation_kv?: null | key_val;
event_presentation_li?: null | [any];
// Concatenated search strings from JOINed views (v_event_session_w_file_count)
event_presentation_li_qry_str?: null | string;
event_presenter_li_qry_str?: null | string;
// A key value list of the files
event_file_kv?: null | key_val;
event_file_li?: null | [any];

View File

@@ -36,6 +36,52 @@ export interface BadgeTemplateCfg {
// Leave unset (or "0") for no bleed.
bleed?: string;
// Header image vertical offset. CSS length applied as margin-top on the badge_header div.
// Default (unset) = "2rem" (matches the prior hardcoded mt-8).
// Negative values shift the image toward the top edge; larger values push it down.
// Any CSS length works: "-0.5in", "1rem", "8px".
header_margin_top?: string;
// Border drawn below the badge header image. Set header_border_color to enable.
// Unset = no border (default). Any valid CSS hex color.
header_border_color?: string;
// Thickness of the header bottom border. Any CSS length. Default "2px" when color is set.
header_border_width?: string;
// Per-side padding of the badge_header div. Any CSS length. Unset = 0.5rem (Tailwind p-2 default).
// Bottom padding creates space between the header image and the border line (e.g. "1.45in").
header_padding_top?: string;
header_padding_right?: string;
header_padding_bottom?: string;
header_padding_left?: string;
// Punch-out hole markers: show X overlays at the physical badge clip slot positions.
// Slots are pre-perforated on the badge stock — markers guide attendees to push them out.
// Hole dimensions: 5/8in wide × 1/8in tall, 1/4in from top, 3/8in from left/right edges.
// Center hole: horizontally centered, same vertical position.
// Colors: per-slot _fg/_bg override the shared fg/bg fallback. Unset = component defaults.
// fg = stroke + line color (hex). bg = rectangle fill color (hex).
punch_holes?: {
left?: boolean;
right?: boolean;
center?: boolean;
fg?: string; // shared fallback stroke/line color
bg?: string; // shared fallback fill color
left_fg?: string;
left_bg?: string;
left_rainbow?: boolean; // animated hue-rotate; overrides fg/bg base color with saturated red
right_fg?: string;
right_bg?: string;
right_rainbow?: boolean;
center_fg?: string;
center_bg?: string;
center_rainbow?: boolean;
slow_pulse?: boolean; // when true: slow breathing pulse instead of fast linear cycle
// Extra horizontal inset per side (mm) beyond the 1mm base safety margin.
// Shrinks the visible marker width to keep it inside the physical hole on
// printers or badge stock with variance. Default 2 when unset (see view component).
inset_x_mm?: number;
};
// Allow arbitrary extra keys to preserve forward-compatibility.
[key: string]: any;
}

View File

@@ -0,0 +1,228 @@
import type { key_val } from '$lib/stores/ae_stores';
export interface BbPostIdentityDefaults {
external_person_id?: string | null;
full_name?: string | null;
email?: string | null;
}
export interface BbPostObjectLike {
post_id?: string | number | null;
id?: string | number | null;
title?: string | null;
topic_id?: string | number | null;
anonymous?: boolean | null;
notify?: boolean | null;
external_person_id?: string | null;
full_name?: string | null;
email?: string | null;
hide?: boolean | null;
priority?: boolean | null;
sort?: string | number | null;
group?: string | null;
enable?: boolean | null;
content?: string | null;
notes?: string | null;
}
export interface BbPostFormValues {
title: string;
topic_id: string | number | null;
anonymous: boolean | null;
notify: boolean | null;
external_person_id: string;
full_name: string;
email: string;
hide: boolean | null;
priority: boolean | null;
sort: string | number | null;
group: string;
enable: boolean | null;
}
export interface BbPostStaffNotificationSiteCfg {
novi_bb_base_url?: string | null;
noreply_email?: string | null;
noreply_name?: string | null;
admin_email?: string | null;
admin_name?: string | null;
}
export interface BbPostStaffNotificationEmail {
from_email: string;
from_name: string;
to_email: string;
to_name: string;
subject: string;
body_html: string;
}
const empty_identity_defaults: BbPostIdentityDefaults = {
external_person_id: null,
full_name: null,
email: null
};
function is_new_bb_post(source_obj: BbPostObjectLike = {}): boolean {
return !source_obj?.post_id && !source_obj?.id;
}
function normalize_identity_defaults(
identity_defaults: BbPostIdentityDefaults = empty_identity_defaults
): Required<BbPostIdentityDefaults> {
return {
external_person_id: identity_defaults.external_person_id ?? null,
full_name: identity_defaults.full_name ?? null,
email: identity_defaults.email ?? null
};
}
export function create_bb_post_form(
source_obj: BbPostObjectLike = {},
identity_defaults: BbPostIdentityDefaults = empty_identity_defaults
): BbPostFormValues {
const is_new_record = is_new_bb_post(source_obj);
const normalized_identity = normalize_identity_defaults(identity_defaults);
return {
title: source_obj.title ?? '',
topic_id: source_obj.topic_id != null ? Number(source_obj.topic_id) : '',
anonymous: source_obj.anonymous ?? null,
notify: source_obj.notify ?? null,
external_person_id:
source_obj.external_person_id ??
(is_new_record ? normalized_identity.external_person_id ?? '' : ''),
full_name:
source_obj.full_name ??
(is_new_record ? normalized_identity.full_name ?? '' : ''),
email:
source_obj.email ?? (is_new_record ? normalized_identity.email ?? '' : ''),
hide: source_obj.hide ?? null,
priority: source_obj.priority ?? null,
sort: source_obj.sort ?? null,
group: source_obj.group ?? '',
enable: source_obj.enable ?? null
};
}
interface BuildBbPostPayloadArgs {
post_di: key_val;
content_html: unknown;
notes_html: unknown;
original_post_obj?: BbPostObjectLike;
identity_defaults?: BbPostIdentityDefaults;
is_new_post: boolean;
account_id?: string | number | null;
}
export function build_bb_post_payload({
post_di,
content_html,
notes_html,
original_post_obj = {},
identity_defaults = empty_identity_defaults,
is_new_post,
account_id = null
}: BuildBbPostPayloadArgs): key_val {
const normalized_identity = normalize_identity_defaults(identity_defaults);
const post_do: key_val = {};
if (is_new_post) {
if (account_id !== null && account_id !== undefined) {
post_do.account_id = account_id;
}
post_do.enable = true;
}
post_do.title = post_di.title;
if (typeof content_html === 'string') {
post_do.content = content_html;
}
post_do.topic_id =
post_di.topic_id !== null && post_di.topic_id !== undefined && post_di.topic_id !== ''
? Number(post_di.topic_id)
: null;
post_do.anonymous = post_di.anonymous;
let identity_person_id: string | null = post_di.external_person_id || null;
let identity_full_name: string | null = post_di.full_name || null;
let identity_email: string | null = post_di.email || null;
if (is_new_post) {
identity_person_id =
identity_person_id || normalized_identity.external_person_id;
identity_full_name = identity_full_name || normalized_identity.full_name;
identity_email = identity_email || normalized_identity.email;
} else {
identity_person_id =
identity_person_id ?? original_post_obj.external_person_id ?? null;
identity_full_name = identity_full_name ?? original_post_obj.full_name ?? null;
identity_email = identity_email ?? original_post_obj.email ?? null;
}
post_do.external_person_id = identity_person_id;
post_do.full_name = identity_full_name;
post_do.email = identity_email;
post_do.notify = post_di.notify;
post_do.hide = post_di.hide;
post_do.priority = post_di.priority;
post_do.sort = post_di.sort ? Number(post_di.sort) : null;
post_do.group = post_di.group ? post_di.group : null;
if (typeof post_di.enable !== 'undefined') {
post_do.enable = post_di.enable;
}
if (typeof notes_html === 'string') {
post_do.notes = notes_html;
}
return post_do;
}
interface BuildBbPostStaffNotificationEmailArgs {
post_do: key_val;
url_origin: string;
site_cfg_json?: BbPostStaffNotificationSiteCfg | null;
}
export function build_bb_post_staff_notification_email({
post_do,
url_origin,
site_cfg_json
}: BuildBbPostStaffNotificationEmailArgs): BbPostStaffNotificationEmail {
const link_base_url =
site_cfg_json?.novi_bb_base_url ?? `${url_origin}/idaa/bb`;
const subject = `IDAA BB Post: ${post_do.title} (ID: ${post_do.post_id})`;
const body_html = `
<div>${post_do.full_name ?? '-- not set --'},
<p>A BB post has been created or updated named "${post_do.title}".</p>
</div>
<div>
Poster's Novi ID: ${post_do.external_person_id ?? '-- not set --'}<br>
Poster's Name: ${post_do.full_name ?? '-- not set --'}<br>
Poster's Email: ${post_do.email ?? '-- not set --'}
</div>
<br>
<div>
IDAA BB Post ID: ${post_do.post_id}<br>
<p>Use this link to view the post.<br>
Copy and paste link: <a href="${link_base_url}?post_id=${post_do.post_id}">${link_base_url}?post_id=${post_do.post_id}</a></p>
</div>`;
return {
from_email: site_cfg_json?.noreply_email ?? 'noreply+idaabb@oneskyit.com',
from_name: site_cfg_json?.noreply_name ?? 'IDAA BB NoReply',
to_email: site_cfg_json?.admin_email ?? 'admin+bbpost@oneskyit.com',
to_name: site_cfg_json?.admin_name ?? 'IDAA BB Admin',
subject,
body_html
};
}

View File

@@ -4,6 +4,7 @@ import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
import { db_journals } from '$lib/ae_journals/db_journals';
import type { ae_Journal } from '$lib/types/ae_types';
@@ -145,13 +146,18 @@ async function _refresh_journal_id_background({
obj_li: [result],
log_lvl
});
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed,
properties_to_save,
log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed,
properties_to_save,
log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal (quota?):', save_error);
}
// Yield to microtask queue so Dexie liveQuery observers fire before we return
await Promise.resolve();
}
@@ -333,13 +339,18 @@ async function _refresh_journal_li_background({
obj_li: results,
log_lvl
});
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed,
properties_to_save,
log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed,
properties_to_save,
log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal (quota?):', save_error);
}
// Yield to microtask queue so Dexie liveQuery observers fire before we return
await Promise.resolve();
}
@@ -418,14 +429,18 @@ export async function create_ae_obj__journal({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal (quota?):', save_error);
}
}
return journal_obj_create_result;
} else {
@@ -531,14 +546,18 @@ export async function update_ae_obj__journal({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal (quota?):', save_error);
}
}
return result;
} else {
@@ -708,7 +727,7 @@ const properties_to_save = [
'outline',
'description',
'description_md_html', // Use the markdown parser to generate HTML
// description_md_html is computed from description on every load — not stored to save IDB quota
'description_html',
'description_json',
@@ -867,9 +886,16 @@ export async function process_ae_obj__journal_props({
const updated =
obj.updated_on ?? obj.created_on ?? new Date(0).toISOString();
obj.tmp_sort_3 = `${obj.group ?? '0'}_${obj.priority ? 1 : 0}_${obj.sort ?? '0'}_${
obj.name
}_${updated}`;
const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({
prefix: [obj.group ?? '0'],
priority: obj.priority,
sort: obj.sort,
fields_2: [obj.name],
fields_3: [updated]
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
obj.tmp_sort_3 = tmp_sort_3;
obj.combined_passcode = `${obj.passcode ?? ''}:${obj.private_passcode ?? ''}`;
return obj;

View File

@@ -4,6 +4,7 @@ import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
import { db_journals } from '$lib/ae_journals/db_journals';
import type { ae_JournalEntry } from '$lib/types/ae_types';
@@ -46,14 +47,19 @@ export async function load_ae_obj_id__journal_entry({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
// Save the updated results list to the database.
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal_entry (quota?):', save_error);
}
}
return journal_entry_obj_get_result;
} else {
@@ -153,19 +159,24 @@ export async function load_ae_obj_li__journal_entry({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
// Save the updated results list to the database.
// IDB write is optional caching — quota failures must not discard the API result.
if (log_lvl) {
console.log('Saving to DB...');
}
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
if (log_lvl) {
console.log('DB save completed.');
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
if (log_lvl) {
console.log('DB save completed.');
}
} catch (save_error) {
console.warn('IDB cache write failed for journal_entry (quota?):', save_error);
}
}
return journal_entry_obj_li_get_result;
@@ -237,14 +248,18 @@ export async function create_ae_obj__journal_entry({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal_entry (quota?):', save_error);
}
}
return journal_entry_obj_create_result;
} else {
@@ -457,13 +472,18 @@ export async function qry__journal_entry({
journal_id,
log_lvl
});
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save,
log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save,
log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal_entry (quota?):', save_error);
}
}
return valid_result_li;
} else {
@@ -588,14 +608,18 @@ export async function update_ae_obj__journal_entry({
if (log_lvl) {
console.log('Processed object list:', processed_obj_li);
}
// Save the updated results list to the database
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
// IDB write is optional caching — quota failures must not discard the API result.
try {
await db_save_ae_obj_li__ae_obj({
db_instance: db_journals,
table_name: 'journal_entry',
obj_li: processed_obj_li,
properties_to_save: properties_to_save,
log_lvl: log_lvl
});
} catch (save_error) {
console.warn('IDB cache write failed for journal_entry (quota?):', save_error);
}
}
return result;
} else {
@@ -848,13 +872,13 @@ const properties_to_save = [
// 'description',
'content',
'content_md_html',
// content_md_html is computed from content on every load — not stored to save IDB quota
'content_html',
'content_json',
'content_encrypted',
'history',
'history_md_html',
// history_md_html is computed from history on every load — not stored to save IDB quota
'history_encrypted',
'passcode_hash',
@@ -1027,19 +1051,20 @@ export async function process_ae_obj__journal_entry_props({
obj.history = history;
obj.history_md_html = history_md_html;
// Journal entry-specific computed sort fields, overriding generic ones if needed
const sort_val = (obj.sort ?? 0).toString().padStart(3, '0');
// Journal entry-specific computed sort fields via build_tmp_sort.
// Order: priority DESC → sort ASC → name ASC → updated ASC (all ascending, no .reverse())
const updated =
obj.updated_on ?? obj.created_on ?? new Date(0).toISOString();
obj.tmp_sort_1 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${updated}`;
obj.tmp_sort_2 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${obj.name ?? ''}_${updated}`;
obj.tmp_sort_3 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${obj.name ?? ''}_${updated}`;
const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({
prefix: [obj.group ?? ''],
priority: obj.priority,
sort: obj.sort,
fields_2: [obj.name],
fields_3: [updated]
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
obj.tmp_sort_3 = tmp_sort_3;
return obj;
}

View File

@@ -0,0 +1,107 @@
export interface JournalEntrySearchParams {
str?: string;
cat?: string | null;
enabled?: 'enabled' | 'all' | 'not_enabled';
hidden?: 'hidden' | 'all' | 'not_hidden';
limit?: number;
}
function normalize_search_value(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.toLowerCase();
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).toLowerCase();
}
try {
return JSON.stringify(value).toLowerCase();
} catch {
return String(value).toLowerCase();
}
}
function build_journal_entry_search_blob(entry: any): string {
return [
entry?.code,
entry?.name,
entry?.short_name,
entry?.summary,
entry?.outline,
entry?.content,
entry?.history,
entry?.notes,
entry?.tags,
entry?.activity_code,
entry?.category_code,
entry?.type_code,
entry?.topic_code,
entry?.group,
entry?.journal_code,
entry?.journal_name,
entry?.alert_msg,
entry?.default_qry_str
]
.map(normalize_search_value)
.filter(Boolean)
.join(' ');
}
export function journal_entry_matches_search(
entry: any,
params: JournalEntrySearchParams
): boolean {
if (!entry) return false;
const qry_str = (params.str ?? '').toLowerCase().trim();
const category_code = params.cat ?? '';
const enabled_mode = params.enabled ?? 'all';
const hidden_mode = params.hidden ?? 'all';
const is_hidden = entry.hide === true || entry.hide === 1;
const is_disabled = entry.enable === false || entry.enable === 0;
if (category_code && entry.category_code !== category_code) return false;
if (enabled_mode === 'enabled' && is_disabled) return false;
if (enabled_mode === 'not_enabled' && !is_disabled) return false;
if (hidden_mode === 'hidden' && !is_hidden) return false;
if (hidden_mode === 'not_hidden' && is_hidden) return false;
if (!qry_str) return true;
return build_journal_entry_search_blob(entry).includes(qry_str);
}
export function journal_entry_compare_for_list(a: any, b: any): number {
// tmp_sort_1 is built by build_tmp_sort() for ascending comparison:
// priority=true encodes as '0', priority=false as '1', so ASC puts priority first.
return (
(a?.tmp_sort_1 ?? '').localeCompare(b?.tmp_sort_1 ?? '') ||
(b?.updated_on ?? '').localeCompare(a?.updated_on ?? '') ||
(b?.journal_entry_id ?? '').localeCompare(a?.journal_entry_id ?? '')
);
}
export function journal_entry_filter_list(
list: any[] | null | undefined,
params: JournalEntrySearchParams
): any[] | null {
if (list === undefined || list === null) return null;
if (!Array.isArray(list)) return [];
const has_text_search = Boolean((params.str ?? '').trim());
const filtered = list
.filter((entry) => journal_entry_matches_search(entry, params))
.sort(journal_entry_compare_for_list);
// Broad views should show the full local result set; only text searches
// should be sliced to a page-sized window.
if (has_text_search && params.limit && params.limit > 0) {
return filtered.slice(0, params.limit);
}
return filtered;
}

View File

@@ -1,7 +1,10 @@
import Dexie, { type Table } from 'dexie';
import { browser } from '$app/environment';
import type { key_val } from '$lib/stores/ae_stores';
import type { ae_Journal, ae_JournalEntry } from '$lib/types/ae_types';
import { IDB_CONTENT_VERSIONS } from '$lib/stores/store_versions';
import { check_and_clear_idb_tables } from '$lib/ae_core/core__idb_dexie';
// li = list
// kv = key value list
@@ -141,3 +144,14 @@ export class MySubClassedDexie extends Dexie {
}
export const db_journals = new MySubClassedDexie();
// On each page load, clear any tables whose content version has changed.
// Versions are defined in store_versions.ts IDB_CONTENT_VERSIONS.journals.
// Each table is only cleared once per version bump (tracked in localStorage).
if (browser) {
check_and_clear_idb_tables({
db_instance: db_journals,
module_name: 'journals',
table_versions: IDB_CONTENT_VERSIONS.journals
}).catch((e) => console.warn('[db_journals] IDB version check failed:', e));
}

View File

@@ -1,5 +1,6 @@
import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { db_posts } from '$lib/ae_posts/db_posts';
@@ -570,15 +571,16 @@ export async function process_ae_obj__post_props({
if (!obj.account_id_random) obj.account_id_random = account_id;
}
obj.name = obj.title;
const sort_val = (obj.sort ?? 0).toString().padStart(3, '0');
const updated =
obj.updated_on ?? obj.created_on ?? new Date(0).toISOString();
obj.tmp_sort_1 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${updated}`;
obj.tmp_sort_2 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${obj.updated_on ?? ''}_${obj.created_on ?? ''}`;
const { tmp_sort_1, tmp_sort_2 } = build_tmp_sort({
prefix: [obj.group ?? ''],
priority: obj.priority,
sort: obj.sort,
fields_1: [obj.updated_on ?? obj.created_on ?? ''],
fields_2: [obj.updated_on ?? '', obj.created_on ?? ''],
pad_width: 8
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
return obj;
}

View File

@@ -1,5 +1,6 @@
import type { key_val } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import { build_tmp_sort } from '$lib/ae_core/core__idb_sort';
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { db_posts } from '$lib/ae_posts/db_posts';
@@ -383,15 +384,16 @@ export async function process_ae_obj__post_comment_props({
obj_type: 'post_comment',
log_lvl,
specific_processor: (obj) => {
const sort_val = (obj.sort ?? 0).toString().padStart(3, '0');
const updated =
obj.updated_on ?? obj.created_on ?? new Date(0).toISOString();
obj.tmp_sort_1 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${updated}`;
obj.tmp_sort_2 = `${obj.group ?? ''}_${obj.priority ? '1' : '0'}_${
sort_val
}_${obj.updated_on ?? ''}_${obj.created_on ?? ''}`;
const { tmp_sort_1, tmp_sort_2 } = build_tmp_sort({
prefix: [obj.group ?? ''],
priority: obj.priority,
sort: obj.sort,
fields_1: [obj.updated_on ?? obj.created_on ?? ''],
fields_2: [obj.updated_on ?? '', obj.created_on ?? ''],
pad_width: 8
});
obj.tmp_sort_1 = tmp_sort_1;
obj.tmp_sort_2 = tmp_sort_2;
return obj;
}

View File

@@ -22,7 +22,7 @@ export interface Post {
external_person_id?: null | string; // For IDAA this is the Novi UUID
user_id?: null | string;
topic_id?: string;
topic_id?: number;
topic?: string; // or topic_name?
topic_name?: string;

View File

@@ -7,6 +7,102 @@ import {
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { db_core } from '$lib/ae_core/db_core';
interface MeetingEvent {
timestamp: string;
action: string;
details: { full_name?: string };
}
interface MeetingParticipant {
displayName: string;
role: string;
novi_uuid?: string;
}
interface MeetingReport {
meeting_id: string;
room_name: string;
start_time: string;
final_duration: string;
final_participant_count: number;
final_participants: MeetingParticipant[];
events: MeetingEvent[];
}
const JITSI_REPORT_PAGE_SIZE = 1000;
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
function safe_parse_meta(raw: unknown): Record<string, unknown> {
if (!raw) return {};
if (typeof raw === 'object') return raw as Record<string, unknown>;
try {
return JSON.parse(raw as string) as Record<string, unknown>;
} catch {
return {};
}
}
function parse_duration_seconds(d: string): number {
if (!d || !d.includes(':')) return 0;
const [h, m, s] = d.split(':').map(Number);
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
}
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function extract_uuid_from_url_params(raw: unknown): string {
if (typeof raw !== 'string' || !raw.trim()) return '';
try {
const params = new URLSearchParams(raw);
return normalize_uuid(params.get('uuid'));
} catch {
return '';
}
}
function extract_participant_uuid(source: Record<string, unknown>): string {
const nested_user = source.user as Record<string, unknown> | undefined;
const nested_context = source.context as Record<string, unknown> | undefined;
const nested_context_user = nested_context?.user as
| Record<string, unknown>
| undefined;
const candidates = [
source.novi_uuid,
source.uuid,
source.novi_customer_uid,
nested_user?.novi_uuid,
nested_user?.uuid,
nested_context_user?.novi_uuid,
nested_context_user?.uuid
];
for (const candidate of candidates) {
const uuid = normalize_uuid(candidate);
if (uuid) return uuid;
}
return '';
}
function extract_flat_search_results(result: unknown): any[] {
if (Array.isArray(result)) return result;
if (
result &&
typeof result === 'object' &&
Array.isArray((result as { data?: unknown }).data)
) {
return (result as { data: any[] }).data;
}
return [];
}
/**
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
* grouped by meeting ID.
@@ -19,12 +115,239 @@ import { db_core } from '$lib/ae_core/db_core';
* @param log_lvl The logging level.
* @returns A structured array of meeting report objects.
*/
function build_jitsi_report_from_logs(
flat_log_list: any[],
log_lvl = 0
): MeetingReport[] {
// Participants come from two sources — both are needed for a complete list:
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
// We merge both into a Map<displayName, participant> per meeting, keeping the
// stable display-name key used by the existing report while preserving Novi UUIDs
// when the log payload includes them.
//
// Duration: take the MAX across all init/stats/end events — periodic stats may
// have a higher value than the final init summary in some Jitsi configurations.
const meetings = new Map<string, MeetingReport>();
const participant_maps = new Map<string, Map<string, MeetingParticipant>>();
const max_duration_secs = new Map<string, number>();
for (const log of flat_log_list) {
const meeting_id = log.external_client_id;
if (!meeting_id) continue;
if (!log.name?.startsWith('jitsi_')) continue;
if (!meetings.has(meeting_id)) {
meetings.set(meeting_id, {
meeting_id,
room_name: 'Unknown',
start_time: log.created_on,
final_duration: '00:00:00',
final_participants: [],
final_participant_count: 0,
events: []
});
participant_maps.set(meeting_id, new Map());
max_duration_secs.set(meeting_id, 0);
}
const meeting_report = meetings.get(meeting_id)!;
const p_map = participant_maps.get(meeting_id)!;
const meta = safe_parse_meta(log.meta_json);
const log_novi_uuid = extract_uuid_from_url_params(log.url_params);
if (log.action === 'jitsi_meeting_init') {
// Strip "Event in room: " prefix Jitsi sometimes prepends to the description
meeting_report.room_name =
(log.description ?? '').replace(/^Event in room:\s*/i, '').trim() ||
'Unknown';
meeting_report.start_time = log.created_on;
}
// Parse duration from init, stats, or end — keep the maximum seen
if (
log.action === 'jitsi_meeting_init' ||
log.action === 'jitsi_meeting_stats' ||
log.action === 'jitsi_meeting_end'
) {
const dur_str = ((meta.duration ?? meta.final_duration) as string) || '';
if (dur_str) {
const secs = parse_duration_seconds(dur_str);
if (secs > (max_duration_secs.get(meeting_id) ?? 0)) {
max_duration_secs.set(meeting_id, secs);
meeting_report.final_duration = dur_str;
}
}
// Merge snapshot participant list (has role info — preferred source)
const snapshot = meta.participants as
| Array<Record<string, unknown>>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
const display_name = normalize_text(p.displayName);
if (!display_name) continue;
const role =
typeof p.role === 'string' ? p.role : 'participant';
const novi_uuid =
extract_participant_uuid(p) ||
(p.role === 'moderator'
? normalize_uuid(meta.moderator_novi_uuid)
: '');
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
continue;
}
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const display_name = normalize_text(meta.full_name);
if (display_name) {
const role =
typeof meta.role === 'string' ? meta.role : 'participant';
const novi_uuid = log_novi_uuid || extract_participant_uuid(meta);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
} else {
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
// Discrete events for the timeline (all non-init actions)
if (log.action !== 'jitsi_meeting_init') {
meeting_report.events.push({
timestamp: log.created_on,
action: log.action,
details: {
full_name: (meta.full_name as string) ?? undefined
}
});
}
}
// Compile final participant lists from the deduplicated maps
for (const [meeting_id, p_map] of participant_maps) {
const meeting_report = meetings.get(meeting_id);
if (!meeting_report) continue;
if (p_map.size > 0) {
// Sort: moderators first, then alphabetically
meeting_report.final_participants = Array.from(p_map.values()).sort(
(a: MeetingParticipant, b: MeetingParticipant) => {
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
if (a.role !== 'moderator' && b.role === 'moderator') return 1;
return a.displayName.localeCompare(b.displayName);
}
);
meeting_report.final_participant_count = p_map.size;
}
}
// Sort events within each meeting chronologically
for (const report of meetings.values()) {
report.events.sort(
(a: MeetingEvent, b: MeetingEvent) =>
new Date(a.timestamp).getTime() -
new Date(b.timestamp).getTime()
);
}
const final_report = Array.from(meetings.values());
final_report.sort(
(a, b) =>
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
);
if (log_lvl) console.log('Final Jitsi report:', final_report);
return final_report;
}
export async function load_jitsi_report_from_cache({
account_id,
log_lvl = 0
}: {
account_id: string;
log_lvl?: number;
}): Promise<MeetingReport[] | null> {
try {
const cached_logs = await db_core.activity_log
.where('account_id_random')
.equals(account_id)
.and(
(log) =>
log.name === 'jitsi_meeting_event' ||
log.name === 'jitsi_meeting_stats'
)
.sortBy('created_on');
if (cached_logs.length === 0) return null;
if (
cached_logs.some(
(log) => typeof log.url_params !== 'string' || !log.url_params
)
) {
if (log_lvl) {
console.log(
'Jitsi report cache is missing url_params; using API refresh for accurate UUID filtering.'
);
}
return null;
}
if (log_lvl) {
console.log(
`Jitsi report cache hit: ${cached_logs.length} activity_log rows`
);
}
return build_jitsi_report_from_logs(cached_logs.reverse(), log_lvl);
} catch (err) {
if (log_lvl) console.warn('Jitsi report cache read failed.', err);
return null;
}
}
export async function qry__jitsi_report({
api_cfg,
account_id,
enabled = 'all',
hidden = 'all',
limit = 500,
limit = JITSI_REPORT_PAGE_SIZE,
try_cache = true,
log_lvl = 0
}: {
@@ -47,27 +370,32 @@ export async function qry__jitsi_report({
and: [{ field: 'account_id_random', op: 'eq', value: account_id }]
};
const result = await api.search_ae_obj({
api_cfg: api_cfg,
obj_type: 'activity_log',
search_query,
headers: { 'x-account-id': account_id },
enabled,
hidden,
limit,
log_lvl: log_lvl
});
const flat_log_list: any[] = [];
const order_by_li: Record<string, 'ASC' | 'DESC'> = {
created_on: 'DESC'
};
let offset = 0;
// Handle potential V3 API envelope
let flat_log_list: any[] = [];
if (Array.isArray(result)) {
flat_log_list = result;
} else if (
result &&
typeof result === 'object' &&
Array.isArray((result as any).data)
) {
flat_log_list = (result as any).data;
while (true) {
const result = await api.search_ae_obj({
api_cfg: api_cfg,
obj_type: 'activity_log',
search_query,
headers: { 'x-account-id': account_id },
enabled,
hidden,
order_by_li,
limit,
offset,
log_lvl: log_lvl
});
const page = extract_flat_search_results(result);
if (page.length === 0) break;
flat_log_list.push(...page);
if (page.length < limit) break;
offset += limit;
}
if (
@@ -95,69 +423,7 @@ export async function qry__jitsi_report({
});
}
// Step 2: Process the flat list into a structured report.
const meetings = new Map<string, any>();
for (const log of flat_log_list) {
const meeting_id = log.external_client_id;
if (!meeting_id) continue;
// Make sure the name field is prefixed with "jitsi_"
if (!log.name.startsWith('jitsi_')) continue;
// Ensure a base entry for the meeting exists
if (!meetings.has(meeting_id)) {
meetings.set(meeting_id, {
meeting_id: meeting_id,
room_name: 'Unknown',
start_time: log.created_on, // Fallback start time
final_duration: '00:00:00',
final_participants: [],
final_participant_count: 0,
events: []
});
}
const meeting_report = meetings.get(meeting_id);
if (log.action === 'jitsi_meeting_init') {
// This is the main log entry, containing the final state.
meeting_report.room_name = log.description;
meeting_report.start_time = log.created_on; // The init log has the true start time
if (log.meta_json) {
meeting_report.final_duration = log.meta_json.duration;
meeting_report.final_participants = log.meta_json.participants;
meeting_report.final_participant_count =
log.meta_json.participant_count;
}
} else {
// This is a discrete event log.
meeting_report.events.push({
timestamp: log.created_on,
action: log.action,
details: log.meta_json
});
}
}
// Sort events within each meeting chronologically
for (const report of meetings.values()) {
report.events.sort(
(a: any, b: any) =>
new Date(a.timestamp).getTime() -
new Date(b.timestamp).getTime()
);
}
const final_report = Array.from(meetings.values());
final_report.sort(
(a, b) =>
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
);
if (log_lvl) console.log('Final Jitsi report:', final_report);
return final_report;
return build_jitsi_report_from_logs(flat_log_list, log_lvl);
}
export const load_jitsi_report = qry__jitsi_report;

View File

@@ -1,9 +1,37 @@
import dayjs from 'dayjs';
// Format pairs: [24h base, 12h variant]. Only formats with both variants are listed.
// Formats without a counterpart (ISO, date-only, week, etc.) are intentionally omitted —
// iso_datetime_formatter passes those through unchanged regardless of use_12h.
const FORMAT_PAIRS: [string, string][] = [
['datetime_iso_no_seconds', 'datetime_iso_12_no_seconds'],
['datetime_short', 'datetime_12_short'],
['datetime_medium', 'datetime_12_medium'],
['datetime_long', 'datetime_12_long'],
['datetime_medium_sec', 'datetime_12_medium_sec'],
['time_long', 'time_12_long'],
['time_short', 'time_12_short'],
['time_short_no_leading', 'time_12_short_no_leading'],
];
// Build lookup maps from the pairs above. Both directions are derived from the same source.
const TO_12H: Record<string, string> = Object.fromEntries(
FORMAT_PAIRS.map(([h24, h12]) => [h24, h12])
);
const TO_24H: Record<string, string> = Object.fromEntries(
FORMAT_PAIRS.map(([h24, h12]) => [h12, h24])
);
export const iso_datetime_formatter = function iso_datetime_formatter(
raw_datetime: null | string | Date = null,
named_format: string = 'datetime_iso_no_seconds', // date_iso, datetime_iso_no_seconds
time_24_hours: boolean = false
// Pass true/false to resolve to the correct 12h or 24h variant automatically.
// null (default) leaves named_format unchanged — all existing call sites unaffected.
use_12h: boolean | null = null,
// When true, treats a naive datetime string (no Z / offset) as UTC so dayjs
// converts it to local browser time on display. Use for timestamps stored as
// UTC in the DB but returned without a timezone indicator.
treat_as_utc: boolean = false
) {
// console.log('*** iso_datetime_formatter() ***');
@@ -50,6 +78,18 @@ export const iso_datetime_formatter = function iso_datetime_formatter(
raw_datetime = new Date(); // Get the current datetime if one was not passed.
}
// Append 'Z' to naive UTC strings so dayjs converts to local browser time.
// Guards against double-appending if the backend ever adds timezone info.
if (treat_as_utc && typeof raw_datetime === 'string' && !raw_datetime.match(/Z$|[+-]\d{2}:?\d{2}$/)) {
raw_datetime = raw_datetime + 'Z';
}
if (use_12h !== null) {
named_format = use_12h
? (TO_12H[named_format] ?? named_format)
: (TO_24H[named_format] ?? named_format);
}
let datetime_string = null;
switch (named_format) {

View File

@@ -1,4 +1,4 @@
import * as Lucide from 'lucide-svelte';
import * as Lucide from '@lucide/svelte';
/**
* Returns a Lucide icon component based on the provided file extension.

View File

@@ -29,6 +29,22 @@ import {
import { get_data_store } from '$lib/ae_api/api_get__data_store';
const JSON_PRETTY_SPACES = 2;
function serialize_json_field_pretty(value: any) {
if (value === null || value === undefined) return value;
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, JSON_PRETTY_SPACES);
} catch {
return value;
}
}
return JSON.stringify(value, null, JSON_PRETTY_SPACES);
}
/**
* Get a list of lookup objects (V3)
* Standardized lookup data like countries, timezones, and subdivisions.
@@ -165,7 +181,7 @@ export const create_ae_obj_crud = async function create_ae_obj_crud({
if (log_lvl) {
console.log(`${key}: ${value}`);
}
data['data_list'][key] = JSON.stringify(value);
data['data_list'][key] = serialize_json_field_pretty(value);
}
}
}
@@ -276,7 +292,7 @@ export const update_ae_obj_id_crud = async function update_ae_obj_id_crud({
if (log_lvl) {
console.log(`${key}: ${value}`);
}
data['data_list'][key] = JSON.stringify(value);
data['data_list'][key] = serialize_json_field_pretty(value);
}
}
}
@@ -549,7 +565,7 @@ export const get_data_store_obj_w_code =
/* BEGIN: Utility: Email Related */
// Updated 2023-06-29
// Updated 2026-05-01 — migrated to the V3 action endpoint
export const send_email = async function send_email({
api_cfg,
from_email,
@@ -605,7 +621,7 @@ export const send_email = async function send_email({
return null;
}
const endpoint = `/util/email/send`;
const endpoint = `/v3/action/email/send`;
data['from_email'] = from_email; // Required
data['from_name'] = from_name;
@@ -648,11 +664,8 @@ export const send_email = async function send_email({
if (log_lvl > 1) {
console.log('Response Data:', send_email_post_promise);
}
if (return_obj) {
return send_email_post_promise;
} else {
return send_email_post_promise.event_abstract_id_random;
}
return send_email_post_promise;
};
/* END: Utility: Email Related */

View File

@@ -176,12 +176,15 @@ function handle_check_access_type_passcode() {
);
}
const kv = $ae_loc?.site_access_code_kv;
if (!kv) return false;
// Reminder: super > manager > administrator > trusted > public > authenticated > anonymous
if (entered_passcode && entered_passcode.length >= 5) {
if (
$ae_loc.site_access_code_kv.super.length >= 8 &&
$ae_loc.site_access_code_kv.super == entered_passcode
kv.super?.length >= 8 &&
kv.super == entered_passcode
) {
console.log('Super passcode matched');
@@ -189,8 +192,8 @@ function handle_check_access_type_passcode() {
$ae_loc.access_type = 'super';
} else if (
$ae_loc.site_access_code_kv.manager.length >= 5 &&
$ae_loc.site_access_code_kv.manager == entered_passcode
kv.manager?.length >= 5 &&
kv.manager == entered_passcode
) {
console.log('Manager passcode matched');
@@ -198,8 +201,8 @@ function handle_check_access_type_passcode() {
$ae_loc.access_type = 'manager';
} else if (
$ae_loc.site_access_code_kv.administrator.length >= 5 &&
$ae_loc.site_access_code_kv.administrator == entered_passcode
kv.administrator?.length >= 5 &&
kv.administrator == entered_passcode
) {
console.log('Administrator passcode matched');
@@ -207,8 +210,8 @@ function handle_check_access_type_passcode() {
$ae_loc.access_type = 'administrator';
} else if (
$ae_loc.site_access_code_kv.trusted.length >= 5 &&
$ae_loc.site_access_code_kv.trusted == entered_passcode
kv.trusted?.length >= 5 &&
kv.trusted == entered_passcode
) {
console.log('Trusted passcode matched');
@@ -216,8 +219,8 @@ function handle_check_access_type_passcode() {
$ae_loc.access_type = 'trusted';
} else if (
$ae_loc.site_access_code_kv.public.length >= 5 &&
$ae_loc.site_access_code_kv.public == entered_passcode
kv.public?.length >= 5 &&
kv.public == entered_passcode
) {
console.log('Public passcode matched');
@@ -225,7 +228,7 @@ function handle_check_access_type_passcode() {
$ae_loc.access_type = 'public';
} else if (
$ae_loc.site_access_code_kv.authenticated == entered_passcode
kv.authenticated == entered_passcode
) {
console.log('Authenticated passcode matched');

View File

@@ -217,26 +217,26 @@ function handle_clear_storage(item: null | string) {
</button>
<button
class="btn btn-sm preset-tonal-warning"
title="Clear the browser storage for this page"
onclick={() => {
title="Full Reset: Delete ALL IndexedDB databases, clear localStorage and sessionStorage for this origin, then reload."
onclick={async () => {
if (
!confirm(
'Are you sure you want to clear the local and session storage?'
'FULL RESET: Delete ALL IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
)
) {
return false;
return;
}
const db_list = await indexedDB.databases();
console.log('[clear_all] IDB databases found:', db_list.map((d) => d.name));
for (const db of db_list) {
if (db.name) indexedDB.deleteDatabase(db.name);
}
// Clear the local and session storage
localStorage.clear();
sessionStorage.clear();
// Clear Indexed DB as well
indexedDB.deleteDatabase('ae_core_db');
indexedDB.deleteDatabase('ae_events_db');
window.location.reload();
// alert('Local and Session Storage cleared and Indexed DBs deleted. You will probably want to refresh the page.');
}}>
<Eraser size="1em" class="mx-1" />
Clear Storage & DB

Some files were not shown because too many files have changed in this diff Show More