v0.15.1

Changelog

Track the evolution of NavViewTZ

Have an idea?

Help shape the future of NavViewTZ

Request a Feature
2026-10-11

v0.15.1: Fund Snippets Without Placeholders, and a Daily Pulse That Knows What Day It Is

The 2026-10-11 Growth Lab review led to a check of what search results actually show for the fund pages it flagged. Two problems turned up. Funds with no 12-month figure printed the "no figure" dash in their title, and UTT funds whose names leave out "UTT" didn't match how people search for them. The same review's numbers also exposed a date bug in the daily GA4 pulse (#175).

Fixed

  • No more "—" in fund titles. A fund without a 12-month return showed "Inuka Dozen Fund NAV TZS 130.20, — 12-Month Return" in search results, because the snippet checked for - while the formatter returns —. Such funds now lead with the NAV alone, and check:prerendered-seo fails the build if a — reaches any fund title or description.
  • The daily pulse reads days in the GA4 property's timezone. A run between midnight and 03:00 EAT paired one day's sessions with the next day's events, as the 2026-10-10 pulse did ("41 of yesterday's 95 sessions"), and left that day without a file.

Changed

  • UTT fund titles name UTT where the fund name doesn't: "UTT Jikimu Fund", "UTT Watoto Fund", "UTT Wekeza Maisha Fund". This follows the Search Console queries "jikimu fund utt" and "watoto fund utt"; other managers are unchanged until query data says otherwise.
  • Growth Lab review 2026-10-11 (docs/growth-lab/2026-10-11.md), with a same-day correction. The figure-led snippets from v0.12.0 have been live since 2026-09-27, and two weeks on, utt-liquid and utt-umoja have barely moved. Next week's bet is moving them up the page, not rewriting their snippets again.

Verified live

  • On 2026-10-11, after Vercel deployed 8ae8c23: /funds/orbit-inukadozen reads "Inuka Dozen Fund NAV TZS 130.20"; /funds/utt-jikimu, /funds/utt-watoto, /funds/utt-wekeza and /sw/funds/utt-jikimu carry "UTT"; and no live fund page title contains "—".
2026-10-08

v0.15.0: One Design System, and Phones First

The app had two colour themes fighting each other (322 transparency classes had silently generated no CSS since April, so tinted badges and panels were invisible and some pages still wore the old green and cyan), no shared components, and four of its most-used pages (Compare, Calculator, Portfolio, Goals) laid out for desktops. This release (#158 to #170) replaces that with one system built from small components, with financial clarity as its guiding principle, and reworks those four pages for phones.

Added

  • A design system: tokens, then 17 components in src/components/ui/ (button, field, card, chip, status pill, stat, delta, page header, notice, empty state, data table, and the six phone pieces below), then pages built from them. /dev/design renders every component in every state, in light and dark. docs/design-system.md is the guide, including its principle (financial clarity) and six phone-layout rules: result first, tables become lists, every action visible and 44px, forms in a sheet, numbers keep their differences, colour means one thing.
  • UiSegmented, UiSwitch, UiMoneyInput, UiListRow, UiMenu and UiSheet: a one-row choice, a labelled switch, an amount with thousands separators and a numeric keypad, the phone form of a table row, an always-visible actions menu, and a bottom sheet that becomes a dialog from md (scrolls, pins its action above the keyboard, traps focus).
  • npm run check:design, part of CI: it fails a pull request that introduces a colour class Tailwind generates no CSS for, a retired pattern (hover scale, off-scale radii, text under 12px, gradients, heavy shadows, raw palette colours), or a control that only appears on hover.
  • Self-hosted Inter, and colour tokens exposed as channels so opacity classes such as bg-success/10 work.
  • Goals: a summary of how many goals are behind pace, on track or past their date, an "Order added" toggle, and a Details disclosure on each card.
  • Compare: a "Reading it" sentence built from the table's own numbers (who returned more over the period, and each fund's volatility and worst drop), each fund's return above the chart, and a Compare button on every fund in the Quick Reference list that fills slot A, then B, through the URL.
  • Calculator: Future, Past and Table tabs on phones, a sticky result bar with an "Edit plan" button, a split showing starting value plus additions against growth, and a "How this is worked out" note.
  • Portfolio: the price is filled with the fund's latest NAV when you choose a fund (editable, and it says so), and transactions are grouped by month on phones.

Changed

  • One accent. Interactive elements are purple; green, amber and red only mean positive, delayed and negative. Subscribe, Confirm, Unsubscribe, Showcase, the fund card, News, Managers and every page header and button were moved onto the system. Nothing is under 12px, there are three radii, and buttons are pills.
  • The newsletter is the Weekly CIS Pulse everywhere, in the web app and in every email.
  • Weekly email: the headline stat tiles are the same height and stack on phones; a fund with no 1-week return no longer sets the average, top gainer or weakest fund ("Strongest" and "Weakest" when nothing rose or fell); text is 12px or larger; and "ranked by 1-week NAV return" says what it ranks.
  • Compare: growth mode ("Compare growth, not price") is the default, because on raw NAV a fund at 124 and a fund at 254 share one axis and the smaller one draws flat. A shared link can still ask for raw prices with normalize=0. The side-by-side table is a grid that fits a phone, the stronger figure gets a check mark instead of a trophy emoji, and the chart has four date labels instead of a dozen rotated ones.
  • Calculator: the three cases show whole figures (neutral and pessimistic used to both read "1.1M"), in purple and slate instead of green, cyan and orange. The horizon is one row of five targets in place of two arrows. The history heading states its own start date.
  • Portfolio: one summary card instead of four, holdings and transactions as lists on phones, and an allocation bar in place of the doughnut below lg. Logging a transaction is a sheet, not a centred dialog.
  • Goals: each card is about half as tall, with one sentence that answers "am I on pace" ("Needs TZS 172,288 a month; you plan TZS 100,000"). The contributions-only disclaimer is said once for the page. A goal's colour tints only its icon and its progress bar is always purple; the picker offers six colours that are not green, amber or red, and goals saved with an older colour keep it.
  • Deleting a goal or a transaction asks in the app, not with a browser dialog.

Fixed

  • Goals: edit, pin and delete could not be found on a phone. They were invisible until hover. They are now a 44px menu on each card.
  • Portfolio: the log dialog had no maximum height or scroll, so on a short phone with the keyboard open its button could sit out of reach; the price field started at 0.
  • Compare: the empty state told phone users to pick funds "from the sidebar", and the iGrowth column of the metrics table was cut off.
  • Weekly email: a fund reported as exactly 0 could be named the weakest.
  • Goals and Portfolio hydrate correctly for visitors with saved data. The shared sheet component rendered its Teleport on the server, and both pages chose between an empty state and a list from saved goals or holdings before the page had mounted. Either broke hydration and left the page body empty (#170). A regression test now runs both pages with saved data against a prerender; the dev server does not prerender, which is why the first version of these pages passed every check but this one.

Removed

  • The fund card's 3D flip (its content is now in a Details disclosure), the "Join hundreds of investors" line on Subscribe, the dead src/data/funds.ts, and GoalTrajectory.vue (replaced by GoalCard.vue).

Verified live

  • Production, in real Chrome at 390px with saved goals and holdings in the browser: Calculator (tabs, headline figure), Compare (growth switch on, the reading sentence), Goals (a 44px actions menu on every card), Portfolio (summary card) and the Swahili Goals page all render, with no sideways scroll, no page errors and no hydration warnings.
  • Not checked: a real phone with a real keyboard, dark mode of Goals, Portfolio and Compare, and the PNG and CSV exports after the Calculator restructure.

Follow-ups

  • An off-white page background and a softer dark-mode border are one-line token changes, not yet applied.
  • Dark mode of Goals, Portfolio and Compare has not been reviewed; the Swahili for the new strings wants a native-speaker read.
  • The daily and monthly ops digests are still off the email kit.
  • No visual-regression snapshots yet (Playwright baselines are platform-specific).
2026-10-07

v0.14.0: Subscriber Emails Get a Proper Look

The weekly pulse and the confirmation and welcome emails were separate hand-rolled templates: a dark header over a plain body, no plain-text part, no dark mode or phone handling, and no way to tell which site visits came from an email. They now share one kit and one look. The ops digests (daily and monthly) keep their own layout.

Added

  • functions/src/lib/emailKit.ts: the shared frame and components for subscriber email. A centred purple header band with the mark, a rounded and thinly bordered card, a hero (label, headline, byline), stat tiles, 48px pill buttons centred in their row and stacked on phones, a greyed centred footer, a dark palette taken from the brand's dark tokens, and a phone breakpoint. 48 new tests cover it.
  • UTM tags on every email link (utm_source=newsletter, utm_medium=email, utm_campaign=weekly-pulse-<week>, utm_content per link), except unsubscribe. Without them an email click lands as Direct in GA4, which is already muddied by bot traffic.
  • A plain-text part on the weekly, confirmation and welcome emails (the digests already had one).
  • A List-Unsubscribe header on the weekly and welcome emails, so Gmail can show its own Unsubscribe link beside the sender. It is a plain link, not one-click; one-click needs a POST endpoint.
  • A "Go beyond the numbers" card in the weekly and welcome emails: one paragraph on what the site offers (fund profiles, the comparator, time-series analysis, the contribution calculator, portfolio tracking, fund news, manager profiles) and one "Explore NavViewTZ" button below it.

Changed

  • The AI summary's headline is now the hero, with the week as a small label above it. A report whose summary has no headline gets a computed one ("Funds averaged −0.16% over the week, led by Imaan Fund at +0.62%").
  • A missing or exactly-zero return or NAV shows "-" (it was "0.00%", "n/a" or "—"), matching the site's report page. The pipeline stores 0 where a fund has no figure, so "0.00%" read as a real result. In Week 40 that is six YTD cells and two 1W cells. The same trade-off as the site: a genuinely flat week also reads "-".
  • The page behind the card is transparent, so Gmail's own rounded container shows through instead of a square patch in a third tone.
  • Welcome email: its first sentence is now its headline, and the old "latest report" button is replaced by the explore card. The confirmation email's copy is unchanged word for word.
  • Contrast: green return figures and the footer text are darker, to clear 4.5:1 on white.

Verified live

  • A real signup made after the deploy produced a confirmation email that arrived through the production SMTP path with the full document intact: the <style> block (phone and dark-mode rules), direct navviewtz.com links with UTM tags, the logo, and a plain-text part. The weekly pulse shares the same kit; its first live send is the next scheduled Sunday.

Follow-ups

  • Tighter AI headline and paragraph prompt, and a one-line explore card in the weekly email, are tracked in #156.
2026-09-28

v0.13.0: /sw/ Is Swahili All the Way Down

An audit of all 71 live /sw/ pages found two kinds of leak. Interface copy that never went through vue-i18n (privacy, terms, support and sources pages, the subscription flow, the 404, the home page's movers panel, and the mobile header on ~20 routes) rendered English on Swahili pages. And missing figures rendered as machine artifacts: raw N/A and TBD catalogue cells, English placeholder copy, and a latent TZS NaN on the Analysis page. Past those, every fund and manager page still quoted its offer-document terms, governance facts and catalogue prose in English. This release closes all three.

Added

  • Swahili renderings of fund data prose (docs/fund-intelligence/translations/sw.json): 533 strings from offer-document terms (reviewed-v2/), manager governance facts (managers/) and the prose columns of funds_overview.csv. Keyed by the exact English source text, so a changed source sentence falls back to English instead of pairing new English with a stale translation. Every entry is a draft until a fluent reviewer checks it against the source document, and the offer-document and governance sections say so, naming the English documents as authoritative. Raw data consumers (WebMCP tools, category tagging, exports) keep reading English.
  • check:data-translations (in prebuild): lists displayed strings with no Swahili entry and entries whose source text is gone. Warns by default; --strict fails.
  • One explicit missing-data state: "Not yet available" / "Bado haijawekwa". isPlaceholderText() treats catalogue placeholder words (N/A, TBD, -, NaN) as empty so they fall through to it.

Changed

  • Privacy, terms, support and sources pages, footer legal links, the subscribe / unsubscribe / confirm flow, the 404 and About pages, changelog chrome, the home movers panel, panel errors, tooltips, export titles and aria labels now go through vue-i18n. Server subscription messages are mapped to translated copy. Release notes stay English on /sw/changelog, labelled and marked lang="en". The Swahili legal copy is a draft pending native legal review.
  • Mobile header titles come from route.meta.titleKey (research pages use their own Swahili title) instead of the English meta.title.
  • Manager directory category chips render through research.categories.

Fixed

  • Unit minimums stated as shillings. formatMoney() stripped every non-digit, so Ushindi's and Thamani's "100 units (value at NAV)" minimum additional investment rendered as "TZS 100". Prose that is not a plain amount now renders as written.
  • TZS NaN on Analysis. NaN.toFixed() returns the truthy string "NaN", which skipped the || '—' fallback. Latest-row figures are now finite-or-null.

Removed

  • Unreferenced views and components (SnapshotView, WeeklyCheckupView, analysis/ComparisonMatrix, WatchlistWidget, DailyPulse, and the Vite scaffold leftovers) with the locale keys only they used.
2026-09-27

v0.12.0: Fund Pages Answer the Search, and a Weekly Growth Lab to Prove It

The first week of real Search Console data showed fund pages already ranking on page 1 (average position 7.3) but rarely clicked: 1.8% CTR, with /funds/utt-liquid at 377 impressions and zero clicks. The queries explained why. Most were people looking for the manager itself ("utt amis", "inuka fund login"), which no snippet wins — but the ones we can win all asked for a number ("utt umoja fund interest rate", "zan timiza etf share price", "vertex etf nav"), and every fund page's snippet was the same feature list: "NAV, Returns, Fees". This release puts the number in the snippet, and adds the weekly pipeline that found the problem so the next run can say whether the fix worked.

Added

  • Growth Lab (scripts/growth-lab.mjs, docs/growth-lab.md, docs/growth-lab-runbook.md): a weekly GSC + GA4 review, sibling to the Site Manager, that asks "what growth bet next" rather than "is the site healthy". Deterministic rules over sensor packs — every finding traces to a number — tagged NEW / ONGOING / REGRESSED / RESOLVED against last week's state.json, rendered as a dashboard and emailed. Reports stay local until reviewed.
  • Search Console sensor (scripts/gsc-check.mjs): site and per-page totals plus query × page rows. Each query is tagged with an intent — navigational, fund name, rate/price, question — from plain keyword lists. A CTR-gap finding now names the queries behind it, and a page whose impressions are mostly navigational stays low-confidence, because the benchmark CTR overstates what it can win. Two site-level findings track the winnable searches: rate/price and question. Raw rows are kept weekly under the gitignored docs/reports/gsc-exports/.
  • "New and upcoming funds" on the dashboard for catalogue funds with no usable performance data yet (Alpha Halal, the initial-offer funds), so they stay discoverable without posing as broken performance cards.

Changed

  • Fund page titles and descriptions lead with the figure. "UTT Liquid Fund NAV TZS 532.02, 11.85% 12-Month Return", with the date of the figure and "unit trusts pay no fixed interest rate" in the description, in the searcher's own words, without claiming a rate that doesn't exist. Yield funds say "annual yield"; ETFs lead with market price. Swahili pages get the same. Figures come from the same data the page renders, so they refresh with every build and in the browser at no extra cost. Funds without NAV data keep the old copy.
  • Growth Lab dashboard and email quote the judged growth bet, not the generated counts line, and gain a search-intent section.

Fixed

  • Catalogue-only funds out of analytical tools. Analysis and the Calculator auto-selected funds with no dated NAV, and Compare offered them, producing empty charts and "Failed to load history for alpha.halal". Selectors now require usable performance data.
  • A history-load failure no longer blanks unrelated pages. It moved from the shared error to its own historyError, which the Analysis chart panel now shows.
2026-09-22

v0.11.0: Insights Gets a Real Home, and Two Funds Get Their Real Category

The three curated research pages shipped last week (/funds/money-market, three /compare/... guides, /market/tanzania-cis) were colliding with real product routes (/funds/:fundId, the interactive /compare tool), orphaned in the footer with no clear home, and cross-linking to literally every other page regardless of relevance. They move to their own /insights/* namespace, with a hub and section indexes above them and real relatedness below them. Building the new category pages also surfaced a live classification bug — and, once that was traced back to its data source, a broader set of stale figures in funds_overview.csv that had nothing to do with routing at all.

Added

  • /insights namespace. /funds/money-market → /insights/fund-categories/money-market, the three comparisons → /insights/fund-comparisons/..., /market/tanzania-cis → /insights/market-overview/tanzania-cis. Old URLs 301 at both the router level (src/router/routes.ts) and the edge (vercel.json), since they were already live and possibly indexed. New hub (InsightsHubView.vue) and three section-index pages (InsightsSectionView.vue, one component driven by route.meta.sectionKind) sit above the existing leaf pages (ResearchView.vue, unchanged in shape).
  • Six new fund-category pages: fixed-income, balanced, ETF, index-funds, multi-asset, and a cross-cutting Shariah-compliant page — the concrete case of a fund belonging to more than one category (itrust.Imaan is both Balanced and Shariah-compliant). Equity was deliberately not given a page: zero tracked funds match it today, and an empty category page repeats the exact thin-content problem this restructure exists to fix.
  • fundTags() (src/data/marketOverview.ts) replaces the old single-category investmentCategory(), which silently mis-bucketed alpha.halal ("Shariah Multi-Asset") into other via a literal /equity/ regex that didn't match "Equities". Every fund now gets a primaryCategory (asset class) plus zero or more crossCuttingTags (currently just shariah, sourced from the CSV's ESG Policy column).
  • "Keep exploring" is now actually related. It used to be "every other research page" (fine at 5 pages, not at 15). Category pages now relate via shared fund tags — the Shariah page surfaces Fixed-Income and Multi-Asset because its member funds are tagged with both, not because someone hardcoded the connection.
  • Cross-links from fund and manager detail pages into their category and comparison pages; the footer's two orphaned mid-row links replaced with a single labeled "Insights" entry.

Fixed

  • itrust.Imaan was misclassified as Fixed-Income (both in funds_overview.csv's Structure column and in the live Firestore type field). The actual offer document (docs/fund-intelligence/reviewed-v2/itrust-imaan.json) describes a Shariah-compliant balanced fund — up to 60% sukuk, up to 50% equities, up to 70% Islamic bank placements — not a fixed-income one. Caught by a second pair of eyes after my first pass got this wrong in the other direction; the CSV and the seed data now both say Balanced.
  • itrust.iSave was misclassified as Money Market / Defensive Balanced. It invests up to 100% in long-term fixed-income securities with no equity allocation — corrected to Fixed Income.
  • itrust.iGrowth, utt.watoto: Firestore type said "Equity"; both are balanced funds per their offer documents and the CSV.
  • alpha.halal: Firestore type said "Balanced"; corrected to Multi-Asset, matching the newly-added category.
  • Two real, unrelated data errors found while auditing the CSV against every fund's reviewed-v2 offer-document extraction: vertex.bond's management fee (was 2.00%, actually 1.20%) and its benchmark (was a flat "13% target return", actually the 10-Year Treasury Bond weighted average yield); orbit.inukaDozen's benchmark (was "Inuka Dozen Index", an invented name — actually TSI Total Return).
  • Nine blank CSV cells filled in from the same source: management fees for both APeF Ziada funds, minimum additional investment for both Sanlam funds, both minimums for tsl.keshotulivu, and benchmarks for orbit.inuka and both Sanlam funds.
  • Two numbers that looked wrong on a first pass (utt.wekeza's TZS 8,340 minimum, utt.jikimu's TZS 15,000 additional-investment minimum) were deliberately left alone — both are correct, just describing a different plan variant than the flattened figure a naive diff compared them against.

Verified

  • node scripts/check-research.mjs, vue-tsc --build (both packages), npx playwright test tests/research.spec.ts (40/40 across two viewports), and a full vite-ssg production build — all green, with the corrected category membership (Imaan off Fixed-Income and onto Balanced, iSave the reverse) confirmed directly in the prerendered HTML before and after the data fix.
  • seedFundMetadata deployed and re-invoked against production Firestore. Confirmed live on navviewtz.com/funds/alpha-halal, which now reads "Multi-Asset" instead of "Balanced" (itrust.Imaan's own type was never actually wrong in Firestore — only in a draft edit that got caught and reverted before deploying, see above). The frontend/CSV side of this change ships with the next site deploy; this Firestore write is independent of and ahead of that.
2026-09-18

v0.10.0: Onboarding Is Now a Tour of the Dashboard

The first-run welcome modal is gone. In its place: three tooltips anchored to the dashboard itself, shown the first time a visitor is on /. Nothing dims, nothing blocks — the page stays scrollable and clickable with a tooltip open, Skip is on every step, and Escape skips too. Same three GA4 events as the modal (v0.9.2), so the two designs read as one funnel split by date.

Added

  • DashboardTour.vue (src/components/onboarding/), mounted in HomeView only. Steps, in order: the Top Movers header ("what's moving"), the table / heatmap / cards switcher ("every fund, your way", with the live fund count), and the sidebar nav ("tools for your own plan", which also makes the case for an account: it is what keeps goals and portfolio with you across devices) — on phones that last one points at the header's menu button, since the sidebar is off-canvas there. The switcher's anchor is its scroll wrapper, not the pill: overflow-x-auto clips a ring on the inner element on every side but the bottom. Anchors are data-tour attributes on the real elements; the anchor gets a ring (a box-shadow, so no layout shift) and the tooltip floats beside it, flipping above when there is no room below and clamped to a 16px gutter. Below md the tooltip is a bottom sheet and the anchor scrolls into view above it. Hand-rolled: driver.js's overlay-and-cutout model is exactly the blocking we did not want, and three steps on one page do not need a library.
  • Trigger is the surface, not the session. A shared-link or weekly-email landing on /funds/:id sees nothing; the tour fires on their first visit to the dashboard, whenever that is. The first anchor is an async panel, so the tour waits for it (MutationObserver, 10s cap) and simply tries again next visit if it never appears. Leaving the dashboard mid-tour is onboarding_dismissed { via: navigate, target }.
  • Swahili copy for all three steps (onboarding.tour.* in sw.json).

Removed

  • OnboardingOverlay.vue and its App-level mount, with the onboarding.welcome* / spotlight* locale keys. The seen-flag and store are unchanged, so nobody who dismissed the modal sees the tour; the Playwright specs that seed navview_onboarding_seen keep working.

Verified

  • Fresh contexts on desktop (1366×768) and phone (390×844): each step rings the intended element; a view-switcher button clicked underneath an open tooltip toggles, and the tooltip stays; Skip, Escape, Get Started and a sidebar link each send the intended payload; a /funds/utt-liquid landing sends nothing and the tour starts on the next click to Dashboard; a returning visitor sees nothing.
2026-09-18

Hydration Spec: Admin Link Broke Every Route on Localhost

tests/hydration.spec.ts had been failing on almost its whole viewport × route matrix since 2026-09-08 with the bare production message Hydration completed but contains mismatches. Nothing runs the spec in GitHub Actions (it needs the prerendered build), so nobody saw it until a local run for the onboarding work.

Fixed

  • The admin nav entry rendered client-side on localhost but never in the prerender. Sidebar.vue decided showAdminLink from isLocalhostHost() during setup; that is false with no window (SSG) and true on the client's first render at 127.0.0.1:4173, so the client tree had an extra <a> the HTML did not — Vue's Hydration node mismatch: rendered on server: <text>, expected on client: a under HYDRATION_DEBUG=1. The sidebar is on every route, hence the whole matrix. Production was never affected (it is not localhost), which is how it hid. The check now waits for mount, like every other browser-only decision in the tree (roadmap 6.2); AdminView.vue had the same pattern and gets the same gate, with isLocalhost added to its load watcher so the localhost fetch still fires once the flag flips.
2026-09-18

v0.9.2: Onboarding Baseline

First-run onboarding is about to be redesigned from a modal into anchored tooltips. Before touching it, the existing modal gets sensors so the redesign has a before/after to be judged against — GA4 only started receiving web hits with v0.9.1, so there is no history to lean on.

Added

  • Onboarding funnel events. OnboardingOverlay.vue now fires onboarding_shown when it actually renders, onboarding_step { step } on every advance, and onboarding_dismissed { step, via, target } on exit, where via is one of skip (the ×), backdrop, done (the final button) or navigate (a spotlight tile — target is its path). step is 1-based so it reads as the visitor saw it. A closed tab sends nothing: abandonment is the gap between shown and dismissed. GA4 attaches page_location to every event, so the share of first exposures that land on /funds/:id (shared links, the weekly email) rather than the dashboard is readable without a custom param. All three are in TRACKED_EVENTS so /admin and the weekly pulse report them.

Fixed

  • Signed-in visitors on a new device saw the overlay flash. The seen-flag starts as localStorage's answer (empty on a new device) and only becomes true once Firestore is read, so the modal rendered for the ~1s that took and then vanished. Now that it is instrumented that would also have been a phantom onboarding_shown. The store exposes resolved — immediate for guests once Firebase settles auth, after the profile read for signed-in users, and set even if that read fails — and the overlay gates on it.

Console follow-up (not code)

  • Register step, via and target as event-scoped custom dimensions (step as a dimension, not a metric — it is a funnel breakdown, not something to sum).
2026-09-17

v0.9.1: GA4 Had Never Received a Hit

The property's Events page showed only the Android widget's stream. No page_view, nothing from the web at all, since the analytics layer shipped on 2026-06-28 — and the "2 users / 3 sessions" the admin dashboard and the weekly pulse have been reporting as site activity were the widget.

Fixed

  • The gtag stub pushed an Array; gtag.js only executes arguments. src/lib/analytics.ts bootstrapped window.gtag as function gtag(...args) { dataLayer.push(args) }. gtag.js's message loop treats a real Array as a different message type — "call this dotted method name on the model" — which fails silently; only an arguments object (what Google's own snippet pushes) is dispatched as a config / event command. So the container loaded, every command was dropped, no _ga cookie was ever set and no hit was ever sent. The stub now pushes arguments. Verified against the live container: _ga and _ga_D32E91F4DG cookies set, and a batched POST to google-analytics.com/g/collect carrying fund_viewed with ep.fund_id, ep.fund_type, ep.manager plus the page_view with its correct title.

Console follow-up (not code)

  • Turn off "Page changes based on browser history events" under the web stream's Enhanced measurement → Page views. Now that hits flow, that setting sends its own page_view on every pushState alongside the one the router sends, so SPA navigations count twice; the manual one is the one with the right title. Everything else in enhanced measurement (scroll, outbound clicks, file downloads) can stay on.
2026-09-17

v0.9.0: GA4 Sensors Where the Traffic Is

Sixteen event names were live, and none of them touched the pages nearly all visitors land on. /, /showcase, /funds/:id, managers, analysis — the free product — emitted page_view and nothing else, and the personalisation tier that shipped in #117 (sign-in, watchlist, goals) had no sensor at all. Separately, /admin only listed nine of the sixteen names, so the middle of the subscribe funnel and every feedback submission were counted by GA4 and invisible in the dashboard.

Added

  • Core product events — fund_viewed {fund_id, fund_type, manager} once per profile in FundDetailView; dashboard_view_changed {view}, dashboard_sorted {surface, key, order} (both the card sort and the table headers) and dashboard_exported {format, fund_count} on the home dashboard; language_changed {from, to} in useLocale; not_found {page_path, referrer_host} on the 404 page so broken inbound links are countable.
  • Personalisation events — login {method} and sign_up {method} in the auth store (GA4's recommended names, so the built-in reports pick them up); watchlist_toggled {fund_id, action, watchlist_size}; goal_created {storage, linked_funds} and goal_deleted {storage}. Goal names and amounts are the user's own figures and are never sent.
  • exception sensor — GA4's recommended event, sent from the Vue app-level errorHandler, window error and unhandledrejection ({description, fatal, source, page_path}). Descriptions are whitespace-collapsed and capped at 150 characters, and a tab sends at most ten per page so a render loop cannot flood the property. A deploy that breaks a page for real visitors now shows up next to the engagement numbers, without a separate error-monitoring vendor.
  • scripts/check-analytics-events.mjs (npm run check:analytics-events, part of prebuild) — scans src/ for every trackEvent('…') call and v-analytics directive and fails the build when a name is missing from TRACKED_EVENTS in functions/src/ga4.ts. The drift that hid the funnel cannot recur silently.

Changed

  • functions/src/ga4.ts TRACKED_EVENTS now lists all 27 custom events, grouped by surface, so /admin and the weekly pulse report the whole funnel: subscribe_pending_confirmation, subscribe_confirm_failed, both post-subscribe CTA clicks, feedback_submitted and feedback_failed were all previously dropped by the inListFilter.

Fixed

  • page_view carried the previous page's title, and was lost in hidden tabs. installAnalytics sent the hit from a requestAnimationFrame after router.afterEach. @unhead/vue writes document.title from a setTimeout(0) scheduled during Vue's flush — always later than that frame — so every SPA page_view reported the title of the page being left. And browsers suspend rAF in background tabs, so a navigation made while hidden (WebMCP driving the page, a tab switched away from mid-click) held the hit until the tab was next shown, or dropped it on close. The hit now waits on unhead's dom:rendered hook (with a one-second fallback), and main.ts passes the head client through for that.
  • Comparison events no longer fire on page load. comparison_fund_selected, comparison_window_changed and comparison_normalize_toggled were emitted from watch()ers on the refs, and applyComparisonQuery sets those same refs when a shared /compare?fund_a=…&fund_b=… link opens or WebMCP repopulates the page. Opening a shared link counted as two selections and a window change. The events now fire from the <select>, button and checkbox handlers, so only a person choosing something is counted.
2026-09-08

Admin Analytics: One Gate, Server-Side

The operator had no way to see the subscriber funnel, the injection wave, app accounts, feedback or GA4 without running scripts against Firestore by hand. /admin puts all five on one page. The interesting part is not the page — it is that the page is not what protects the data.

Added

  • functions/src/adminAnalytics.ts — the dashboard's only data source. Requires an Authorization: Bearer <id token> header, verifies it with the Admin SDK's verifyIdToken, and checks the decoded email claim against ADMIN_EMAILS before reading a single document. Everyone else gets a bare 403 {"error":"Forbidden"}, identical whether the token was absent, forged, expired or valid-but-someone-else's — there is nothing in the response to tune against.
  • src/views/AdminView.vue at /admin — subscriber status counts, the active list, confirmation rate, recent signups with signup_provenance (IP, user agent, origin, fill_ms); spam volume by day with top IP and user-agent clusters and classifier verdicts; expired pending; app accounts; feedback, unread first; and GA4 over a selectable 7/28/90-day window. A jump nav with live counts sits under the header, because the panel you actually work — feedback — is at the bottom of a long page.
  • Admin entry in the sidebar (src/lib/admin.ts, shared by the view and Sidebar.vue), shown only to the operator, so /admin no longer has to be typed by hand.
  • functions/scripts/check-admin-endpoint.mjs (npm run check:admin:remote --prefix functions) — probes the deployed URL four ways: no token, garbage token, a valid token for a throwaway account it creates and deletes, and a valid admin token. Unit tests prove the gate's logic; only this proves the deployed function is wired to it.
  • 28 tests covering the gate (allowed, denied, missing, forged, malformed header, unverified address, emulator bypass) and the aggregation. 300 total passing.

Changed

  • functions/src/ga4.ts — extracted getGa4Stats(propertyId, days); getWeeklyGa4Stats now delegates to it with 7. No call-site changes.
  • firebase-admin promoted from a transitive dependency to a declared one, and marked --external in the esbuild bundle rather than inlined.
  • /admin and /sw/admin are noindex — useSeo.ts emits noindex, nofollow and vercel.json sets the matching X-Robots-Tag, the way /goals, /portfolio and /settings are handled.

The rule this follows

A Vue route guard hides a page; it does not protect a payload. Anyone can read a response out of the network tab, or skip the app entirely and curl the endpoint. So /subscribers, /spam_subscribers and /expired_pending_subscribers stay closed in firestore.rules — they hold IPs and user agents belonging to people who never consented to anything — and this function reads them with the admin SDK instead. The client-side check is UX; deleting it would leak nothing.

There is deliberately no localhost bypass on the deployed function. Origin: http://localhost:5173 is a string the caller chooses, and trusting it would hand the subscriber table to anyone willing to set a header. The only unauthenticated path is FUNCTIONS_EMULATOR, which the emulator sets in its own process and which cannot exist in production. On localhost the page therefore renders signed-out, but the data still needs a real admin token.

Verified live

All four probes pass against the deployed endpoint. What the first authorised call surfaced is worth recording: all 9 active subscribers carry no confirmed_at — they predate double opt-in, when the old handler wrote active on the form POST alone. A bare 0.0% confirmation rate reads as a live outage, so the payload now carries active_unconfirmed_count and the dashboard names the legacy tail explicitly rather than letting the number mislead.

2026-09-08

Signup Classifier: Stop Mailing the Victims

Double opt-in already stopped injected addresses becoming subscribers, but each attempt still provoked one confirmation email from the owner's Gmail to a person who never asked for it. Roughly one an hour, from a real sending reputation. This files confident injections straight to /spam_subscribers and mails nobody.

Added

  • functions/src/signupClassifier.ts — scores a first-contact signup and routes a confident injection to /spam_subscribers with no email. The response is identical to a genuine signup, so a submitter learns nothing they could tune against, and an address that already has a record is never reclassified.
  • 23 classifier tests plus 3 integration tests (272 total passing). The false-positive cases are the real contract: a missed injection costs one email, a false positive silently drops someone who wanted the newsletter.

The design rule

Score attacker-controlled signals, never victim attributes. User agent, IP and timing are chosen by the bomber; the email domain is chosen by the person being attacked. All 277 injections were on Yahoo-operated mail, but blocking Yahoo would lock out real Yahoo users while the attacker simply loads a different list — so yahoo_family_domain carries weight 1 and cannot convict alone, a property pinned by test.

ua_malformed is the load-bearing signal: every WebKit browser sends (KHTML, like Gecko) after its AppleWebKit version, and the wave's two user agents both omit it. That is a defect, not a preference, so it convicts alone.

Measured

5/5 real injections caught, all on ua_malformed. 0/15 false positives among genuine subscribers, whose highest score was 2 against a threshold of 6. Verified live: the attacker fingerprint filed to /spam_subscribers with no mail sent, a real browser got its confirmation, both saw identical responses.

Known limit

Every signal is client-supplied and spoofable; ua_malformed is a one-line fix for the attacker. A test pins the gap — once they fix the UA and leave the known datacenter ranges, the remainder totals 5 and passes. Closing it needs ASN reputation or a challenge such as Turnstile. This cuts mail to victims; it does not secure the list. Double opt-in does that.

2026-09-08

Subscriber Archival: Spam and Expired-Pending Collections

/subscribers still held every quarantined injection, so its size was meaningless and any ad-hoc query against it was a trap. Records that are not people now live elsewhere — moved, never deleted, because the injection wave was diagnosable only from the shape of the records it left behind.

Added

  • /spam_subscribers — 277 classified injections moved out of /subscribers, which now holds 17 records (9 active, 3 test, 2 retired, 2 pending, 1 interested). Includes the two addresses that reached pending under double opt-in and matched the injection signature.
  • purgeExpiredPendingSubscribers (functions/src/subscriberArchive.ts) — daily at 04:15 EAT, archives pending signups whose seven-day confirmation window has closed into /expired_pending_subscribers. Without it the collection slowly refills with the noise double opt-in exists to stop, since an injected signup never confirms and so never leaves pending on its own.
  • scripts/migrate-spam-subscribers.mjs — dry-run by default; --apply to write, --also-injected= to relabel named addresses not yet marked injected.
  • 7 contract tests covering archival: records are moved not lost, every original field survives (provenance is the point of archiving), unexpired and expiry-less pending records are left alone, non-pending statuses are never swept up, and batches beyond the 500-operation Firestore limit split cleanly.

Changed

  • firestore.rules — both archive collections are closed to all client reads and writes. They hold IP addresses and user agents belonging to people who never consented to anything, so only admin-SDK tooling touches them.
2026-09-08

Subscription Integrity: Double Opt-In & Provenance

An audit of /subscribers found the collection had grown from 11 records to 286 in eight days. 275 of those were a subscription-bomb injection against scraped addresses: 94% on yahoo.com/att.net, 274 of 275 with a claimed name sharing no token with the email local-part, and signups spread uniformly across all 24 UTC hours with no nightly trough. Because manageSubscription accepted an unauthenticated POST from any origin, wrote status: "active" immediately, and sent a welcome email to whatever address arrived, NavViewTZ had emailed ~275 non-consenting people from the owner's Gmail — a sender-reputation problem as much as a data one.

See docs/subscription-integrity.md for the full forensic breakdown and the operational guide.

Added

  • Double opt-in (functions/src/subscriptions.ts): subscribe now writes status: "pending" with a random 32-byte token and a 7-day expiry, and sends only a confirmation email. A new confirm method validates the token and is the sole path to status: "active" — and the only point at which the welcome email and admin notification fire. Consent is now a recorded fact (confirmed_at) rather than an inference from subscribed_at.
  • /confirm route (src/views/ConfirmSubscriptionView.vue): verifies the emailed token and carries the "You're In!" celebration that previously fired on form submit. Marked noindex, follow alongside /unsubscribe.
  • Request provenance: every write records IP, user-agent, origin, referer, accept-language, client-reported form fill time, and whether the origin was allowlisted. The injection wave left no forensic trace at all because the old handler logged only {method, email, status}.
  • scripts/audit-subscriber-provenance.mjs: reports the funnel and confirmation rate, IP/user-agent clusters, non-allowlisted origins, sub-second fills, and address-shape heuristics that still work on pre-provenance records.
  • scripts/quarantine-injected-subscribers.mjs: classifies and quarantines an injection wave. Requires all three signals to agree before acting, reports weaker matches as REVIEW, and flips status rather than deleting so the call stays auditable.
  • 15-minute resend cooldown per address. Double opt-in alone caps mail-bomb damage at one email per request; without a cooldown a retry loop rebuilds the original attack. Within the window the existing token stays valid, so a person who lost the first email is not locked out.
  • Contract tests (functions/src/__tests__/subscriptions.test.ts): 16 tests covering the properties the wave exploited — a signup can never produce an active subscriber on its own, and can never mail a third party more than one confirmation request.

Changed

  • Honeypot on the subscribe form, matching the existing feedback.ts pattern. A filled website field returns a normal success response so a scripted submitter gets no signal to adapt to.
  • Origin allowlist: subscribe requires an allowlisted Origin (browsers always send one on a cross-origin POST). confirm/unsubscribe are exempt as top-level navigations from email clients. CORS reflects only allowlisted origins instead of *.
  • unsubscribe no longer discloses whether an address is on the list, and subscribe returns an identical message for new, pending, and active addresses — neither is an address-existence oracle any more.
  • Subscribe page copy now says "Check your email" rather than claiming success, and the stated cadence is Sunday (it read Friday, contradicting both the welcome email and the actual send schedule).
  • getTransporter uses a dynamic import rather than require, which is correct under both the emitted CommonJS and the ESM the test runner produces.

Data

  • 274 records reclassified: 271 → injected, 3 owner test accounts → test. Four ambiguous records were left active pending manual review. The genuine confirmed list is 5 subscribers.
2026-09-08

Subscriber Visibility & Weekly Engagement Reporting

Discovered that email subscribers were signing up with zero visibility beyond spotting outgoing mail in Gmail's own Outbox — manageSubscription wrote straight to Firestore with no confirmation email, no admin notification, and no dashboard. Separately, the weekly site-manager report (repo/ops health, delivered only via PR) had no idea real users existed: GA4 engagement data was already being computed for the subscriber pulse email but discarded rather than surfaced anywhere useful.

Added

  • Subscriber + unsubscribe notifications (functions/src/subscriptions.ts): every subscribe/resubscribe/unsubscribe now sends a self-email so signups are visible immediately, without checking Gmail's Outbox. Best-effort — a mail failure never blocks the underlying Firestore write.
  • Welcome email: new and reactivated subscribers get a short confirmation that enrolment worked, states the weekly cadence, and invites feedback by reply.
  • Feedback banner in the weekly pulse email (functions/src/reports.weeklyPulse.ts): one minimal line pointing to /feedback, alongside the existing unsubscribe link.
  • Engagement sensor wired into the site-manager routine: scripts/ga4-check.mjs (built earlier but never actually hooked into the weekly routine) now also pulls subscriber/feedback growth from Firestore, and a new scripts/notify-engagement-report.mjs composes a weekly "what are users actually doing" email (traffic, subscriber growth, feature usage, flagged issues) — sent directly via the routine's Gmail connector, separate from the PR-only site-manager report. See docs/site-manager-runbook.md §3b and docs/site-manager.md (Stage 6, now shipped).
2026-09-01

Mobile Navigation Fixes

Testing on mobile surfaced a cluster of navigation problems: several routes (Goals, Portfolio, Settings, Subscribe, Feedback, and others) hid the shared mobile header via hideMobileHeader: true — but that header's hamburger button was the only way to open the sidebar on mobile, so those pages left mobile visitors with no navigation at all.

Fixed

  • Mobile header restored on Goals, Portfolio, Settings, Subscribe, Feedback, Unsubscribe, Privacy, Terms, Support, and Sources (src/router/routes.ts) — sidebar access, page title, and now a back button are available on every page again.
  • Back button added to DashboardLayout.vue's mobile header, but scoped via a new meta.showBackButton flag to only the routes that are actually unreachable from the sidebar nav (fund/manager detail, /report, and the footer/legal pages) — pages already in the sidebar (Goals, Portfolio, Settings, Weekly Reports) rely on the hamburger alone, matching how the sidebar already gets you anywhere. Ordered hamburger-then-back, per design review.
  • Duplicate "Log Transaction" button on PortfolioView.vue — the top-right button and the empty-state CTA both rendered at once; the top-right one now only shows once holdings exist.
  • Goals page layout: "Add Goal" moved from the header row to a centered button below the goal grid, matching mobile-app conventions.
  • Redundant in-page back controls removed now that the shared header covers it: FeedbackView.vue, SubscribeView.vue, and UnsubscribeView.vue each had their own "Back" link plus a bg-surface-muted full-page background — both left over from when these pages had no shared layout at all. Removed both, along with the min-h-screen vertical-centering wrapper that was pushing content far down the page under the new header. PublicPolicyView.vue (Privacy/Terms/Support) and SourcesView.vue had the same redundant "Back to NavViewTZ" link, also removed.

Known follow-up

  • Filed #116: route navigation to a lazy-loaded page can show a blank flash while its JS chunk downloads, with no loading indicator — most visible on a slow mobile connection. Confirmed it's not a broken route (content and prerendered HTML are both intact); needs a top-of-page progress indicator.
  • Firebase auth/unauthorized-domain on sign-up is a Firebase Console authorized-domains configuration item, not a code fix — left for the owner.
2026-09-01

Fund Intelligence v2 Migration & Governance Sections

The reviewed fund-intelligence layer (offer-document terms, provenance) existed only as a flat v1 schema covering a handful of funds, with no place to record manager-level facts — custodian, trustee, auditor, board, related-party disclosure — that apply identically across every fund a manager runs but had been hand-typed per fund in the catalogue CSV instead, drifting out of sync.

Added

  • v2 schema, wired end-to-end: every fund (all 24 pre-existing funds, extracted family-by-family from iTrust, UTT, Orbit, Vertex, APeF, Zansec, WHI, TSL, and Sanlam) and every manager now has a reviewed-v2 / managers JSON record. src/data/fundIntelligence.ts and functions/src/fundIntelligence.ts load these and thread them through fundPresentation.ts's mapToFund(); publicExports.ts and scripts/build-field-provenance.mjs read v2 too. The old flat v1 schema (docs/fund-intelligence/reviewed/*.json) is deleted now that v2 fully replaces it.
  • Governance sections on both fund and manager pages (FundDetailView.vue, ManagerDetailView.vue, shared via a new governanceFacts() helper in fundPresentation.ts): custodian/trustee (with background), board appointment, auditor, legal advisor, registrar, related-party disclosure, regulator, ownership type, and complaints channel — sourced from the manager record, so it renders even for funds without their own v2 file. Manager pages also show a financials narrative (AUM/income/profit history) where a manager's v2 file has one.
  • Sanlam deep-research pass: custodian, trustee, board, and related-party facts for Sanlam funds, previously the thinnest-covered manager.
  • Ushindi and Thamani Funds onboarded (Victory Financial Services) — full v2 fund-intelligence records built from their Issue-2026 offer documents via the prospectus pipeline, catalogue rows, manager file, and web-ready logos split from the combined VFSL artwork. Brings the total catalogue to 26 funds.

Fixed

  • Custodian/trustee sourcing: regenerating field-provenance from v2 data surfaced real catalogue/manager mismatches for iTrust, Vertex, Zansec, Sanlam, and Orbit funds. The catalogue's flat Trustee/Custodian CSV column is now fully retired as a source — the web app, the public agent-facing exports (scripts/generate-agent-surface.mjs), and funds_overview.csv all read the verified manager-level value instead.
2026-08-31

Fund Page Accuracy & Discoverability

Added

  • Offer-document terms extended from 7 to 19 funds (FundDetailView.vue's "What the offer document says" section): minimums, loads, lock-ins, eligibility, and settlement windows, each carrying a citation that states how far its review has gotten so a compiled-but-unchecked entry never claims a verification that hasn't happened. Values with no approved source now read as unverified rather than silently blank.
  • Catalogue reconciliation tooling (prospectus/reconcile.mjs, manager-pages/fetch.mjs): compares the hand-filled catalogue against the extracted prospectus corpus and against managers' own product pages for minimum investment, inception, management fee, and redemption period, reporting every disagreement with a citation. Decisions are recorded and bound to the value they were reviewed against, so a later edit reopens them rather than silently going stale.
  • Server-rendered NAV and "Data as of" date (#111) on fund pages — previously only a headline return and fund size rendered server-side, not the NAV per unit itself or its freshness, weakening the signal the sitemap already publishes via lastmod. mapToFund()'s dataAsOf now normalizes the same way generate-sitemap.mjs computes lastmod, so the two can never disagree; check-prerendered-seo.mjs asserts they match.
  • Fund and manager pages genuinely translated in Swahili (#113) — FundDetailView.vue and ManagerDetailView.vue had almost no t() calls, so /sw/ fund pages rendered mostly in English chrome. Presentation-layer labels (Custodian, Management Fee, Not published, TBD, etc.) are now translated at the source in fundPresentation.ts, picked up automatically by FundCard.vue and both detail views.

Fixed

  • vercel.app alias no longer indexable — the production deployment alias (nav-view-tz.vercel.app) served the full site alongside the canonical www.navviewtz.com domain, letting search engines see two copies of every page. Now permanently redirected at the edge (vercel.json), path-preserving, so deep links still land correctly.
2026-08-29

First-Run Onboarding & Account Settings

Roadmap 4.3/4.6.

Added

  • First-run onboarding overlay (OnboardingOverlay.vue): a skippable, one-time welcome + feature-spotlight flow linking straight into Goal Tracker, Portfolio Tracker, Compare, and Calculator for new visitors who previously landed on the dashboard with no explanation of what NavViewTZ does.
  • /settings page: profile editing (display name, via a new authStore.updateUserProfile()), plus theme and language preferences, reachable from a new standalone sidebar entry so signed-out visitors can reach preferences too.

Fixed

  • Pinia SSR state leak: verifying onboarding against the actual production build (not the dev server, which never exercises SSG prerendering) surfaced a pre-existing bug — main.ts captured Pinia state by reference instead of a snapshot in router.beforeEach, so any store instantiated during render (onboarding, and likely a signed-out visitor's local goals/holdings) leaked into the server-rendered hydration payload and had its correct client-computed value clobbered by the server's "no window" default. Fixed with an explicit, filtered snapshot taken before render creates any other store.
2026-08-28

Comparator: Growth Metrics Matrix

The /compare page (FundComparison.vue) previously only showed a line chart — no way to see how two funds actually stacked up without eyeballing the chart. Brought it closer to parity with DSEasy's comparator.

Added

  • Growth Metrics table below the chart: for each selected fund, 1M / 3M / 6M / 1Y growth (computed from history via nearest-date matching, same approach as DSEasy's CompareTickers.tsx), plus growth/peak/trough for whatever period is currently selected in the sidebar — labelled Selected (90d) etc. so the window is never ambiguous. 🏆 marks the better fund per row.
  • 30-day volatility and max drawdown rows, sourced from volatility_30d / max_drawdown — already computed server-side (functions/src/enrich.performanceMetrics.ts) and used in the monthly email briefing, but never surfaced anywhere in the frontend until now. Verified live against Firestore: populated on all 24 funds. Volatility is intentionally colored neutral (not green/red) and wins on the lower value — the one metric where "less" is the good direction.
  • Custom HTML legend replacing Chart.js's canvas-drawn one: each fund's actual logo, ringed in its series color with a light matching tint over the logo itself (not the plate — an early version tinted the white background instead and made dark-colored logos disappear in dark mode), at a larger font than the old default.
  • Growth Metrics table headers get the same treatment (logo + colored ring) instead of a plain color dot.
2026-08-28

Sidebar Cleanup

Removed

  • Manual "Refresh Data" sidebar button (src/components/Sidebar.vue) — redundant with the automatic refresh App.vue already triggers (fundsStore.refresh(true)); removed the button, its isRefreshing spin state, and the now-unused useFundsStore import from the sidebar. The store's own refresh() method is untouched — only the manual UI trigger is gone.
2026-08-27

WebMCP Surface and Firestore Fallbacks

Seven read-only WebMCP tools now expose fund research to in-browser agents, three of which drive the comparison and calculator pages through query parameters. Building the deep links surfaced a class of silent failure: the pages looked correct while showing nothing.

Fixed

  • Deep-linked comparisons never loaded history (src/components/FundComparison.vue) — fund ids seeded from the query during setup did not trigger the NAV history watchers, so a cold load rendered the right funds, period and toggle over a permanently empty chart. Seeding moved into onMounted. The same change removes a hydration mismatch logged on every deep-linked load, which the existing 52-test hydration suite missed because it only visits query-less URLs.

  • Fund ids were compared case-sensitively — the catalogue, static data files and agent surface all published itrust.icash while the Firestore document is itrust.iCash, so a link built from any published id left the fund selector blank. Ids now resolve case-insensitively on both the comparison and calculator pages, and the catalogue is aligned to the Firestore spelling. Firestore document paths are case-sensitive, so the same mismatch had also made the history export read a nonexistent path and write no snapshot for iCash at all.

  • A Firestore outage rendered blank pages with no explanation — an unreachable Firestore does not reject; it resolves an offline-cache read as an empty snapshot, which is indistinguishable from "no data". Both the fund list and NAV history had Firestore as their only source.

Added

  • Deployment snapshots as a fallback (scripts/generate-fund-history.mjs) — 400 days of NAV per fund exported to public/data/history/ during prebuild, and fetchLatest falls back to the existing public/data/latest.json. Together these keep the selectors populated and the charts drawn when Firestore is unavailable.

  • Saved figures are labelled (src/components/SnapshotNotice.vue) — the store records whether data came from a live read or a snapshot, and the dashboard, analysis, calculator and comparison pages show the as-of date when serving saved data, so it is never presented as current.

  • check:fund-ids in prebuild (scripts/check-fund-ids.mjs) — a catalogue id whose casing differs from its Firestore document id now fails the build instead of silently producing a blank selector and an empty export. Funds catalogued before their first Firestore document warn rather than fail.

Notes

  • WebMCP requires #enable-webmcp-testing in Chrome 151; without it document.modelContext is undefined and no tool registers. Tool behaviour is covered against an injected harness in tests/webmcp.spec.ts, not a real agent host.
  • The fallback path is proven against the built bundle in test, not against production, since exercising it there means breaking Firestore.
2026-08-22

Monthly Digest: Fix Blank Pipeline Activity

The Monthly Digest email (introduced 2026-06-21) had been sending with "Pipeline Runs / Snapshots Written / Errors Logged" all showing 0 and no indication anything was wrong — confirmed live via triggerMonthlyDigest for July's digest.

Fixed

  • Missing composite index for the pipeline activity aggregate (firestore.indexes.json) — gatherMonthlyDigest's count()/sum() aggregate query over pipeline_logs (filtered by timestamp range) was throwing FAILED_PRECONDITION: The query requires an index on every run. The surrounding try/catch swallowed it and defaulted to 0/0/0, so the email always looked "sent successfully" even though that section never loaded. Source reliability and coverage used different queries and were unaffected, which is why only the activity numbers were blank.

Added

  • sectionErrors on the digest (functions/src/reports.monthlyDigest.ts) — each data-gathering step (pipeline activity, source health, funds/ coverage) now records its failure instead of just logging a warning. A red "🛑 Data Collection Failed" banner renders in the email when any section fails, the subject/status icon reflects it instead of showing "✅ Clean month," and triggerMonthlyDigest's JSON response includes the real error — this is what made the missing index diagnosable in minutes instead of requiring log archaeology.
2026-08-12

v0.4.1 MCP Guardrail Fixes from Live ChatGPT Evaluation

Addresses the 4 open failures from the 2026-08-11 live ChatGPT evaluation (docs/submission/evidence/2026-08-11/evaluation-report.md). All four are prompt/description/output-text guardrails plus one structural widget fix; none can be confirmed against the real ChatGPT host without a fresh live evaluation run.

Added

  • MCP server instructions (functions/src/mcp/server.ts) — a server-wide guidance string (surfaced via client.getInstructions()) telling the model to always call a tool for current figures, resolve fund_ids from get_market_briefing/search_funds before calling compare_funds, never call compare_funds more than once per request and merge results into a larger ranking (max 5 funds per call), and never recommend a fund when a comparison is incompatible.
  • Structured error card — errorResult() now returns structuredContent: {error, disclaimer} alongside the existing error text, and the widget (functions/src/mcp/ui.ts) renders an "Unable to complete request" card instead of staying blank. Fixes the empty output-template iframe ChatGPT showed above the unknown-fund error response. Doesn't apply to SDK-level input-validation errors (e.g. the 5-fund-limit rejection), which never reach the widget.

Changed

  • compare_funds incompatible-comparison warning — both the structuredContent.warning and the model-facing text now explicitly say "do not recommend one fund over another, or say which one you would choose or buy," closing the gap where the widget correctly blocked an incompatible comparison but the model still verbally recommended a fund.
  • Tool descriptions (functions/src/mcp/contracts.ts) — strengthened get_market_briefing, search_funds, and compare_funds with explicit discovery-before-comparison and 5-fund-cap/no-chaining guidance.
  • SKILL.md (plugins/navviewtz/skills/tanzania-fund-research/) — same guardrails reinforced at the skill layer: no chaining compare_funds calls across a single request, and an explicit no-recommendation instruction for the comparable: false case.

2026-08-11

v0.4.0 Public MCP Server for ChatGPT & Agents

Publishes NavViewTZ's read-only fund data as an MCP (Model Context Protocol) server at https://www.navviewtz.com/mcp, so ChatGPT and other MCP-aware agents can query Tanzania fund data directly instead of scraping the site or the static JSON exports.

Added

  • navViewMcp Firebase function (functions/src/mcp/) — a stateless Streamable HTTP MCP server exposing four read-only tools: get_market_briefing, search_funds, get_fund_details, compare_funds, plus a compact HTML UI resource for ChatGPT's app surface. Reads a single cached public_exports/latest_funds Firestore aggregate; capped at 3 instances / 30s timeout.
  • headline_metric_status (comparable / stale / insufficient) — threaded through the enrichment pipeline (enrich.performanceMetrics.ts), public exports, and the static agent-surface generator, so rankings and MCP tools only ever compare funds on genuinely current, same-basis metrics instead of silently mixing in stale or missing ones.
  • plugins/navviewtz — a Codex/ChatGPT plugin manifest pointing at the MCP endpoint.
  • functions/scripts/check-mcp-endpoint.mjs and docs/mcp-operations.md — a remote health check and the release/monitoring runbook for this endpoint.

Fixed

  • enrichFund() headline carry-forward — a force re-enrich could null out performance_basis/headline_metric for funds with a dedicated collector (Sanlam's published-yield feed), because the in-memory enrichment record used to build the public export didn't reflect what the Firestore merge: true write actually preserved. Added existingHeadlineFields() to carry those fields forward explicitly, with unit coverage.
  • /mcp routing — the SPA's Edge Middleware (middleware.ts) was intercepting /mcp and returning its own 404 before the vercel.json rewrite to the Firebase function could run; /mcp is now excluded from the middleware's matcher.

2026-07-26

Indexable Swahili Pages (closes #56)

Publishes the Swahili experience at its own crawlable URLs. sw.json already covered the app UI, but the Swahili pages had no URL of their own and the crawlable shells rendered by middleware.ts were English-only, so from a search engine's point of view the Swahili site did not exist.

Added

  • /sw locale tree - every public page is mirrored at a /sw prefix (/sw, /sw/compare, /sw/funds/utt-liquid, /sw/managers/itrust-finance). Routes are declared once and mirrored programmatically in src/router/index.ts, so a new route is covered in both locales automatically.
  • Swahili crawlable copy - the middleware's prerendered shells, page titles, meta descriptions, table labels, and JSON-LD descriptions are translated (~106 strings). Terminology follows the register already established in sw.json: mfuko/mifuko, mapato for NAV return and mazao for published yield, kitengo, ada, ukwasi, NAV kept as-is.
  • hreflang annotations - every page emits reciprocal en, sw, and x-default alternates server-side, and public/sitemap.xml carries the matching xhtml:link blocks (88 URLs across 2 locales).
  • Locale-aware regression checks - check-seo-middleware.mjs now asserts Swahili rendering, lang="sw", prefix-internal links, Swahili 404s, and hreflang reciprocity in both the responses and the sitemap.

Changed

  • Locale resolution - a /sw URL now outranks localStorage and the browser default, so a visitor arriving from a Swahili search result is not flipped back to English.
  • Language switcher - switching locale navigates to the same page in the other tree (/compare ↔ /sw/compare) instead of leaving the URL contradicting the rendered language.
  • Fund profile intro - uses the curated catalogue category instead of the raw structure field, fixing the ungrammatical "as a Open-end Money Market" in English and giving Swahili a translated noun phrase.

2026-06-28

v0.3.2 Phase 3 Technical Maturity Completed

Marks Phase 3 as complete after the final User Insight & Analytics work landed.

Changed

  • Roadmap Phase 3 - moved from "Current Focus" to "Completed" now that sections 3.1-3.6 are all checked off.
  • Phase 4 handoff - Phase 4 Personalization & Ecosystem is now the next roadmap focus.

2026-06-28

Optional GA4 Interaction Tracking (closes roadmap §3.5 Interaction Tracking)

Adds a lightweight analytics boundary that is silent unless VITE_GA_MEASUREMENT_ID is configured.

Added

  • src/lib/analytics.ts - initializes GA4/gtag on demand, emits SPA route page views, and exposes trackEvent() plus a v-analytics directive for declarative click tracking.
  • Feature-flow events - feedback opens, subscription success/failure, calculator fund/projection changes, comparison fund selections, comparison window/normalize changes, and comparison PNG/CSV exports.

Changed

  • Roadmap §3.5 - Interaction Tracking is now marked complete; Feedback Mechanism was already complete.
  • README - documents the optional VITE_GA_MEASUREMENT_ID deployment setting and the privacy boundary for tracked events.

All notable updates and features for NavViewTZ are documented here.


2026-06-21

Reporting Overhaul: Monthly Digest, Source Uptime Metrics & Distinct Senders

Reworks the Cloud Functions reporting layer so the admin gets a clear daily digest, a dedicated monthly retrospective, and reliability metrics — with each email channel visually distinct in the inbox.

Added

  • Source uptime/downtime metrics (functions/src/sourceHealth.ts) — A durable per-source reliability ledger (meta/source_health) accumulated from incident transitions. Each resolved incident becomes a downtime episode; summarizeWindow() computes per-source uptime %, incident count, and total downtime over any window. Fed automatically from runIncidentCheck, so no extra pipeline work. Unit-tested in src/__tests__/sourceHealth.test.ts.
  • Monthly Digest report (functions/src/reports.monthlyDigest.ts) — New monthlyDigestReport scheduled function (1st of month, 7am EAT) emailing a retrospective of the previous calendar month: pipeline activity totals, a least-reliable-first source uptime leaderboard, snapshot coverage (gaps/duplicates/pending), and funds stale at month close. Includes a triggerMonthlyDigest HTTPS endpoint for on-demand previews.
  • Daily report header summary — The daily QA email header now carries an at-a-glance issue summary line (e.g. "3 errors · 2 stale · 1 schema change", or "No issues found").

Changed

  • Daily report schedule — Moved from 0 8 * * 1-5 (8am, Mon–Fri) to 0 7 * * * (7am, every day) so weekend NAVs are reported on.
  • Distinct email senders (functions/src/config.ts MAIL_SENDERS) — Reports now send as "NavViewTZ Reports", incident/schema alerts as "NavViewTZ Alerts", and the subscriber weekly pulse as "NavViewTZ Insights", so each channel is filterable in the inbox.

Removed

  • Day-5 monthly-coverage block on the daily email — superseded by the standalone monthly digest. The old block silently skipped any month whose 5th fell on a weekend (the daily only ran Mon–Fri).

2026-06-20

In-App Feedback Link (closes roadmap §3.5 Feedback Mechanism)

Routes user feedback through the existing Google Form already wired up from the changelog page, instead of standing up a separate form/backend or sending users to GitHub Issues.

Added

  • src/lib/externalLinks.ts — single source of truth for off-site URLs. Currently exports FEEDBACK_FORM_URL; future additions (WhatsApp invite, social links) belong here too.
  • Send feedback footer link (SiteFooter.vue) — opens the existing Google Form in a new tab (target="_blank", rel="noopener noreferrer"), uses the lucide MessageSquarePlus icon to match the existing footer-link visual pattern. Single entry covers both feature requests and bug reports — the form's own categorisation handles routing.

Changed

  • ChangelogView.vue — switched its inline FEATURE_REQUEST_FORM_URL constant to import FEEDBACK_FORM_URL from @/lib/externalLinks, so the URL lives in exactly one place. The existing "Request a Feature" CTA on the changelog page is unchanged behaviour-wise.
  • Footer link nav — switched from gap-8 to gap-6 with flex-wrap so the new entry lays out cleanly alongside Subscribe / WhatsApp / Methodology / About across the responsive breakpoints.

Verified

  • npm run build passes — no new warnings.

2026-06-20

v0.3.1 Sticky Mobile Header & Managers Catalogue Polish

Refactors the mobile layout with a unified, sticky inline top header and page-aware title system. Polishes the Managers Catalogue page with sort controls, structured category tags, and layout enhancements.

Added

  • Sticky Mobile Header — Replaced the floating hamburger button with a 56px sticky top bar inside DashboardLayout.vue, containing the hamburger inline with the page title.
  • Dynamic Mobile Titles (usePageTitle) — Introduced a usePageTitle composable to dynamically set the mobile header title based on the active view (e.g. manager or fund name), with unmount cleanup to prevent title clobbering.
  • Managers Catalogue Sorting — Added a Sort By control to the /managers page matching the homepage pattern (Name, Fund count, Earliest fund) with ascending/descending toggles.
  • Managers Catalogue Metric Strip — Added a top metric strip under the header summarizing total managers, tracked funds, and the earliest inception date.
  • Fund Category Badges — Derived a categories[] list from each manager's funds to render category tags on manager cards.
  • Local Dev Launch Config — Added .claude/launch.json defining launch profiles for Vite dev, preview, and Firebase functions emulator.

Changed

  • Mobile Viewport Optimization — Hidden in-page H1 headers on mobile for dashboard-managed views (Home, Fund Grid, Analysis, Compare, Calculator, Reports, Managers) to maximize vertical space.
  • Managers List & Cards Design — Replaced prose summary with category chips, structured manager stats into distinct pills, and added hover lift animation effects.
  • High-Contrast Brand Plates — Placed manager logos on a white card background plate to ensure readability of dark transparent logo glyphs in dark mode.
  • Tabulated Growth Scrolling — Made the main growth tab strip horizontally scrollable to prevent viewport overflow on narrow screens.
  • Methodology Header Polish — Adjusted Methodology page sticky header z-index to z-20 to render beneath the sidebar menu.

2026-06-20

Per-Fund Enrichment Dirty Flag (closes roadmap §3.3)

Closes the last open item under Pipeline Reliability. enrichAllFunds now distinguishes funds whose NAV history actually changed this run (full re-enrichment) from funds that didn't (light-touch consensus refresh), cutting steady-state Firestore reads from enrichment by ~95% per the audit projection.

Added

  • EnrichOptions.advancedFunds: Set<string> (functions/src/enrich.performanceMetrics.ts) — opt-in scoping that gates which funds get the expensive snapshot-read + metric-recomputation path. When omitted, all funds full-enrich (legacy behaviour for triggerEnrichment and cleanAndRebackfillUTT).
  • classifyFundForEnrichment(...) — pure helper exporting the full | light decision rule so the gate is exercisable without a Firestore emulator. Handles legacy mode, advanced membership, never-enriched first-run defence, and empty/undefined sets.
  • Light-touch pass — funds not in advancedFunds get their existing /funds/{id} doc patched with the three consensus-relative fields (consensus_date, days_behind_consensus, freshness_status) and pass through into /meta/fund_summary with cached metrics intact. No snapshot read, no /enriched/{date} write.
  • functions/src/__tests__/enrich.partition.test.ts — 12 unit tests pinning the classifier's behaviour across legacy mode, advanced membership, never-enriched fallback, empty sets, undefined sets, and realistic overnight / post-deploy / manual-trigger scenarios.

Changed

  • syncFundData pipeline (functions/src/index.ts) — when enrichment runs, passes advancedFunds = new Set(persistedFunds) so only funds that actually persisted new rows trigger a full recompute. The outer hasAdvanced gate (skip enrichment entirely when zero funds advanced) is unchanged.
  • enrichAllFunds log lines now report the full vs. light split each run, so steady-state reduction is visible in Cloud Logging without bespoke metrics.
  • /meta/fund_summary correctness preserved — every fund still contributes, so widget/dashboard reads stay single-document. Funds that didn't advance contribute cached metrics overlaid with the latest consensus snapshot.

Verified

  • npm test in functions/ — 115 / 115 tests pass (12 new partition tests, 14 existing calendar tests, 89 pre-existing).
  • npm run build in functions/ — esbuild bundle compiles cleanly (157.2 kb).
  • Pre-existing reports.weeklyPulse.ts:131 typecheck failure (unrelated Array.prototype.at lib target) is unchanged.

2026-06-20

Tanzania Holiday Calendar for d1 Metrics

Closes roadmap §3.1 by replacing the weekend-only prevCanonicalWorkingDay with prevPublicTanzaniaWorkingDay, a holiday-aware step that drives the canonical_start field surfaced with d1 metrics.

Added

  • TZ_PUBLIC_HOLIDAYS set (functions/src/enrich.performanceMetrics.ts) — gazetted Tanzania public holidays for 2026, covering New Year, Zanzibar Revolution Day, both Eid el-Fitr days, Good Friday, Easter Monday, Karume Day, Union Day, Labour Day, Eid al-Adha, Saba Saba, Nane Nane, Maulid, Mwalimu Nyerere Day, Independence Day, Christmas, and Boxing Day.
  • prevPublicTanzaniaWorkingDay(dateStr) — bounded backward walk that skips both weekends and gazetted holidays; degrades transparently to weekend-only logic for dates outside the loaded years (no regression risk).
  • functions/src/__tests__/enrich.calendar.test.ts — 14 unit tests across normal weekdays, weekend traversal, single-day clusters (Saba Saba, Nyerere Day, Eid al-Adha), multi-day clusters (Easter+Karume = 5-day gap, Eid el-Fitr weekend, Christmas weekend), the 2025→2026 boundary, and out-of-coverage degradation.

Changed

  • calcD1Return canonical_start derivation now reflects the real expected previous trading day during holiday clusters (e.g. Wed 8 Apr 2026 → Thu 2 Apr 2026, stepping over the full Good Friday + Easter Monday + Karume cluster), instead of mislabelling Tue 7 Apr.
  • Doc comments throughout enrich.performanceMetrics.ts updated to reference the new helper and the gazetted calendar; the "future hook" wording removed.
  • PERIOD_TOLERANCE_DAYS justification clarified — tolerances stay unchanged for now and will be revisited based on observed STALE outcomes over a full quarter rather than calendar-coverage alone.

Verified

  • npm test in functions/ — 103 / 103 tests pass (including the 14 new calendar tests).
  • Pre-existing typecheck failure in reports.weeklyPulse.ts:131 is unrelated to this change.

2026-06-20

v0.3.0 Managers Catalogue, Weekly Reports Redesign & Sorting Controls

Aggregates a complete fund managers profile catalogue with local branding assets, modernizes the weekly report dashboard layout and list chronology, and introduces interactive card-ordering controls and table-sorting indicators.

Added

  • Managers Catalogue & Detailed Profiles (/managers & /managers/:managerSlug) — Full directory of the 9 fund managers, mapped to high-quality local logo assets in public/logos/, with custom summaries, earliest tracked inception dates, and dynamic managed fund cards.
  • Homepage Card Ordering Controls — Added a compact, inline sorting panel next to the Deep Dive Cards header, allowing users to sort cards by Fund name, Manager, Latest date, NAV price, 1W return, YTD return, and 1Y return, including a square toggle button to switch between ascending/descending order.
  • Sortable Weekly Report Tables — Interactive headers for the weekly report table (Fund, NAV, NAV/Unit, 1 Week, YTD) that display sort-direction icons and toggle order when clicked.

Changed

  • Weekly Report Layout Redesign — Removed custom page-level margins and email-style wrappers from the weekly report details page, refactoring the view into dashboard-native containers while maintaining print/export capabilities.
  • Newest-First Weekly Report Sorting — Normalized the weekly reports archive list to always sort newest-first using Firestore timestamps and fallback heuristics for incomplete records.
  • Branding & Logo Integrations — Mapped manager profiles to their respective high-quality local logo PNG files in public/logos/ instead of fetching live site favicons.
  • Homepage Actions Polish — Routed homepage table action buttons directly to /analysis?fund=<fund_id> to preselect the clicked fund on the analysis view.

2026-06-18

v0.2.2 Fund Pages & Discovery

Adds dedicated in-app fund and manager pages, strengthens internal navigation from the dashboard and showcase, and lays the groundwork for search discovery with crawler-friendly metadata and sitemap assets.

Added

  • Fund profile pages (/funds/:fundId) - Dedicated pages for each tracked fund with NAV context, returns, fees, liquidity, benchmark, operating notes, and official source links.
  • Manager profile pages (/managers/:managerSlug) - In-app landing pages that group tracked funds by manager and link back into fund profiles.
  • Shared fund presentation mapper (src/data/fundPresentation.ts) - Centralized fund/manager route IDs, slugs, metadata merging, and presentation shaping for cards and profile pages.
  • Crawler entry points - Added public/robots.txt and public/sitemap.xml with fund, manager, and core app URLs.

Changed

  • Internal fund navigation - Fund names and CTAs in the showcase now open NavViewTZ fund pages instead of sending users straight to external manager sites.
  • Dashboard links - Fund names and manager names in Table Summary, Top/Bottom Movers, Deep Dive Cards, and Performance Heatmap now route to in-app pages.
  • Route metadata - Extended Vercel middleware metadata for /funds/... and /managers/..., including canonical URL injection and fund-specific titles/descriptions.
  • Vercel rewrites - Excluded robots.txt and sitemap.xml from the SPA catch-all rewrite so crawlers can fetch them directly.

2026-06-18

Fund Logo Fallback & Layout Polish

Implements high-fidelity fallback UX for fund logos when specific fund logos are missing or fail to load. This release adds the official Vertex logo, falls back to manager logos with custom CSS initials badges inside the logo circle, dynamically generates colorful HSL gradient backgrounds based on the fund ID or name when manager logos are also missing, and hides the badge on small UI footprints to prevent clipping or distortion.

Added

  • Vertex International Securities logo (public/logos/vertex.png) — Added the official logo.
  • Dynamic HSL gradients — Implemented helper gradientStyle in FundLogo.vue to generate high-quality, professional linear HSL gradients deterministically from the fund name or ID.

Changed

  • FundLogo.vue fallback hierarchy — Updated component logic:
    1. Try loading specific fund logo (/logos/<fundId>.png).
    2. Fall back to manager logo (/logos/<providerId>.png) with a small initials badge at the bottom-right.
    3. Fall back to full-container initials with a modern gradient background if the manager logo is also missing.
  • Initials deduplication — Added a explicit initials lookup map (FUND_INITIALS_MAP) to prevent identical initials for different funds (e.g. ZI for Ziada vs. ZI+ for Ziada Insured).
  • Adaptive layout sizing — Hidden the initials badge and centered the manager logo for very small container sizes (anything smaller than h-8 like the h-5 Select Fund sidebar logo) to avoid clipping and text distortion.
  • Double concentric borders fix — Cleaned up wrapper classes to eliminate nested double border lines passed down from parent components.

2026-06-16

Orbit Collector: Drop Livewire, Use Static Panels

Orbit's redesign now renders both fund tables (money_market, dozen_index) as static data-history-panel blocks in the initial page HTML — no Livewire round-trip required. This release rips out the CSRF / livewire/update POST flow entirely and slices the two panels apart with a bounded extraction so they can't bleed into each other.

Changed

  • collectOrbit() — Reduced from 3 sequential HTTP calls (page GET + 2 Livewire POSTs) to a single page GET. Removed CSRF extraction, Livewire snapshot parsing, session-cookie forwarding, and the Livewire POST helpers. The historical-table location strategies (id → heading → most-rows-wins) are unchanged.
  • Panel isolation (extractSegment) — Slices each panel from its data-history-panel="<name>" marker up to the next data-history-panel= marker so a panel can't pick up tables from an adjacent panel. The leading-space requirement scopes the match to HTML attributes and ignores inline-script string literals that mention the same attribute name.
  • Missing-panel fingerprint — When a panel is absent the collector emits a sentinel fingerprint (MISSING_PANEL:<name>) alongside the COLLECTION FAILED alert, so the layout canary will fire when a panel reappears with a different structure.
  • spot-check.mjs — Orbit checks now probe for the two data-history-panel markers and ≥2 NAV-shaped tables in the initial HTML instead of CSRF token / Livewire snapshot probes.

Notes

  • The CSRF/Livewire fallback ("a failed Livewire call no longer aborts the whole collector", added 2026-06-15) is moot now that the Livewire codepath is gone, but the COLLECTION FAILED alert contract it introduced is preserved on the missing-panel branches.

2026-06-15

Source-Change Monitoring & Orbit Collector Hardening

Orbit's site changed its NAV table structure (collapsing to a DATE + NET ASSET VALUE layout) and the collector silently produced zero rows for ~a month without raising any alert. Root cause: the existing schema check only fires when a table is found and parsed; a moved/missing table returned empty with no signal. This release closes that blind spot and makes the Orbit scraper resilient to layout changes.

Added

  • Collection-gap detection (detectCollectionGaps) — Any fund we actively collect that has prior history but returns zero rows in a run now raises an immediate SchemaAlert, routed through the existing same-run email path. Guards against false positives: only funds in the configured collectible set and with existing snapshots are flagged (discontinued/never-published funds are ignored).
  • Layout canary (detectLayoutChanges) — Each HTML source's header-row signature is fingerprinted per run and compared against the previous run (stored in /meta/source_fingerprints). Structural changes that do not break column mapping (reorders, additions, cosmetic rewrites) now trigger an early-warning alert before data silently degrades.
  • Orbit collector fallbacks — Locates the historical NAV table by id → "Historical NAV Table" heading → header-signature (largest matching table), and falls back to the server-rendered initial page HTML when the Livewire response yields no table.

Changed

  • collectOrbit() — A failed Livewire call (e.g. HTTP 419) no longer aborts the whole collector; it is caught, fallbacks are attempted, and an explicit COLLECTION FAILED alert is raised when no table can be found. Whatever recognised columns are present (at minimum DATE + Net Asset Value) are ingested rather than dropping the fund entirely.
  • CollectorResult — Gained an optional fingerprints map (fund_id → header signature) for the layout canary.
  • Immediate alert email — Generalised from "Schema Change Detected" to "Source Alert" to cover schema changes, layout changes, and stopped-data conditions.

Added

  • persistedFunds tracking in pipeline logs — The syncFundData pipeline now captures and stores exactly which fund IDs received new snapshots during a run.
  • Enhanced "Funds Updated" list in Daily QA Report — The morning report now uses the new persistedFunds data to provide a 100% accurate list of updated funds per run, including the specific time they were updated. This replaces the previous heuristic and ensures that if a run fails or skips a fund, it is correctly reflected in the report.

Changed

  • transformAndPersist() — Now returns an object {count, fundIds} instead of a single number, providing better downstream visibility for the pipeline.
  • gatherDailyStats() — Refactored to leverage persistedFunds for the "Funds Updated" table in the daily email digest.

2026-04-30

Functions Audit & Maintenance Documentation

Added

  • scripts/audit-functions.mjs — New comprehensive audit script for Google Cloud Functions and Cloud Run.
    • Fetches pipeline run success rates from Firestore pipeline_logs.
    • Pulls Cloud Monitoring metrics (invocations/errors) and Cloud Logging entries.
    • Estimates infrastructure costs via cost proxy.
    • Generates structured markdown reports in docs/reports/.
  • scripts/README.md — Central documentation for all maintenance, diagnostic, and asset management scripts.

Changed

  • Agent.md — Added "Weekly Maintenance Tasks" section to formalize the use of audit scripts.

2026-04-23

Scraper Reliability Fixes: fetchWithRetry & Orbit CSRF

Root cause analysis of two recurring scraper failures seen across multiple pipeline runs on Apr 23. Both WHI and Orbit scrapers failed on every run despite target websites being online. Identified and fixed two bugs — one critical (affects all scrapers), one Orbit-specific.

Fixed

  • utils.ts — Broken retry logic in fetchWithRetry (Critical, affects ALL scrapers)

    The AbortController and its timeout were created once and shared across all retry attempts. Once the 30s timeout fired (or the first fetch encountered a network error that aborted the signal), every subsequent retry was instantly killed because an aborted signal stays aborted forever. This made the retry mechanism a no-op for any request that failed on the first try.

    Additionally, the clearTimeout() call was only in the success path — on failure, the timer leaked and could fire mid-retry-sleep, poisoning the next attempt.

    Fix: Create a fresh AbortController + setTimeout per attempt, and clearTimeout in both success and error paths.

    BeforeAfter
    1 AbortController for all 4 attempts1 AbortController per attempt
    Timeout fires once, kills all retriesTimeout scoped to each attempt
    Timer leaked on catchTimer cleared on catch
  • collect.orbit.ts — CSRF token never sent to Laravel (Orbit-only, caused HTTP 419)

    buildHeaders() set X-Livewire, Content-Type, User-Agent, Referer, and Cookie — but never set X-CSRF-Token. Then livewirePost() tried to read headers["X-CSRF-Token"] for the request body _token field, which was always undefined, falling back to extractTokenFromSnapshot() which returned the Livewire snapshot checksum — not the Laravel CSRF token. Laravel correctly rejected every POST with HTTP 419 "Page Expired".

    Fix:

    1. buildHeaders() now includes "X-CSRF-Token": csrf in the returned headers object
    2. livewirePost() now receives csrf as a direct parameter and uses it for the body _token field, eliminating the fragile header-lookup indirection

Verified

  • TypeScript build: ✅ clean (esbuild, 87.1kb bundle)
  • Unit tests: ✅ 42/42 passed (all 5 test suites including collect.orbit.test.ts)
  • Live site checks: ✅ Both whi.go.tz and orbit.co.tz responding normally

Core Purpose

NavViewTZ is a modern, real-time performance dashboard designed for Tanzania's mutual funds ecosystem. It empowers investors, fund managers, and financial enthusiasts to:

  • Track Performance — Monitor live market cap, ROI, and key fund metrics at a glance
  • Analyze Trends — Explore historical data with time-series charts and momentum tracking
  • Compare Funds — Make informed decisions with side-by-side fund comparison tools
  • Simulate Investments — Project wealth growth and contribution outcomes with an interactive calculator
  • Stay Informed — Access weekly performance reports and deep insights

Tech Stack: Vue 3, Vite, Chart.js, TailwindCSS, Google Sheets (backend → migrating to Firebase/Firestore)


2026-04-23

Orbit Collector Fix, Backfill & Gap-Aware Staleness

Fixed

  • collect.orbit.ts broken Livewire component — Orbit split their page into two components: net-asset-value (live summary) and historical-net-asset-value (historical table). The collector was calling toggleTable on the wrong component, causing HTTP 500 since at least Apr 21. Fixed by targeting historical-net-asset-value for the Livewire interaction. Collector is working again — no data loss on existing Firestore data.

  • spot-check.mjs smoke check — updated to verify historical-net-asset-value instead of net-asset-value, so a future component rename will be caught at deploy time.

  • collect.orbit.test.ts fixtures — updated component name in both the HTML fixture and the Livewire response mock.

Added

  • scripts/backfill-orbit.mjs — one-off backfill script that fetches the full Orbit historical table with no depth limit, diffs against Firestore, and writes only missing dates. First run filled 44 gaps: 13 for orbit.inuka and 31 for orbit.inukaDozen (AppScript-era single-row scraping holes and the recent Apr 21–22 collector outage).

  • Gap-aware staleness in staleness.ts — getStalenessMap now scans the last PIPELINE.GAP_SCAN_ROWS (60) snapshots per fund instead of just the latest one. If any consecutive gap exceeds PIPELINE.GAP_THRESHOLD_DAYS (5 days), daysStale is bumped to cover back to the gap edge. This feeds deriveFetchLimit which then returns a deep enough window to fill the gap on the next collector run — self-healing without manual intervention.

  • PIPELINE.GAP_SCAN_ROWS: 60 added to config.ts.

⚠️ Reverted — Gap-Aware Staleness

The limit(60) gap scan in getStalenessMap was reverted in commit 08fda80. It was over-engineered: the Livewire collector fix and the backfill-orbit.mjs one-off already resolved all known gaps, making the per-run scan unnecessary. The pipeline is back to limit(1) for staleness detection. Gap monitoring responsibility moved to the monthly coverage check (see below).


2026-04-23

Stage 3 Migration Complete & Monthly Coverage Check

Migration: Stage 3 Complete

  • scripts/validate-stage3.mjs — fixed a field name mismatch: the validator was reading return_1m, return_3m, sparkline from Firestore but the enrichment function writes ret_1m, ret_3m, sparkline_1y. All 16 funds now pass Stage 3 validation (16/16 ✅).
  • Shadow week running until 2026-04-29. Stage 4 (flip frontend to Firestore) begins after that date.

Added

  • Monthly coverage check in the 5th-of-month daily email (functions/src/reports.dailyEmail.ts) — On the 5th of each month, the morning QA email gains an extra block summarising the previous calendar month's snapshot coverage for all 16 funds. For each fund it reports: gap count (consecutive gaps > GAP_THRESHOLD_DAYS), duplicate date count (data integrity violations), and a "pending" flag for slow publishers whose latest snapshot predates the 28th. Implemented by querying each fund's snapshots subcollection with limit(35); no new Cloud Function required.

2026-04-23

Firestore Audit Expanded to All 16 Funds

Changed

  • scripts/firestore-check.mjs — expanded from 7 funds (iTrust × 6 + Zansec) to all 16 funds across every collector. Funds are now grouped by manager for readable output, run in parallel via Promise.all, and report a summary table at the end.

    New funds added: whi.faida, orbit.inuka, orbit.inukaDozen, utt.bond, utt.jikimu, utt.liquid, utt.umoja, utt.watoto, utt.wekeza

    ChangeDetail
    Per-fund stale thresholdWHI gets 7 days (government fund); all others 3 days
    Parallel Firestore readscount(), recent snapshots, and oldest-date queries run concurrently per fund
    Exit code1 on any hard error (missing fund doc / empty subcollection); 0 on warnings-only or clean
    Summary tablePrinted at end with total errors, total warnings, and per-fund breakdown

    First live run results: 16 funds audited, 0 errors, 19 warnings — all warnings are the expected Easter 2026 holiday gap (Apr 2 → Apr 8) and historical Orbit gaps from before the collector was wired up.


2026-04-23

Live Smoke Pre-Deploy Gate

Added

  • scripts/spot-check.mjs expanded to all 5 data sources — previously only covered iTrust and Zansec. Now verifies every upstream source before each deploy:

    SourceCheck
    iTrust (JSON API)All 6 expected fields present; dates parse as MM/DD/YYYY; core numeric fields non-null
    Zansec (HTML table)table#datatable present; all 6 expected column headers match; latest 3 rows parse cleanly
    WHI (HTML table)table#example1 present; all 6 expected column headers match; latest 3 data rows parse cleanly (footer rows filtered by digit-presence test)
    UTT AMIS (DataTables API)Page loads and CSRF token present; /navs API returns rows; at least one sname maps to a known scheme; latest date parses as DD-MM-YYYY
    Orbit (Livewire)Page loads; data-csrf attribute present; wire:snapshot for the net-asset-value component is present
  • Exit code gate — script now exits 1 on any hard error, blocking the Firebase deploy. Warnings (extra columns, date spread) are logged but do not block.

  • Parallel execution — all 5 source checks run concurrently via Promise.all; total wall time ≈ 20–30 s.

  • Per-source summary table — printed at the end of every run so pass/fail is visible at a glance even when check output is interleaved.

Changed

  • firebase.json — added "node scripts/spot-check.mjs" as the third predeploy step (after lint and build). Any source-site structural change now blocks the deploy rather than silently producing null NAV data after it goes live.

2026-04-22

Fixture-Based Unit Tests for All Collectors

Added

  • Vitest test suite (functions/src/__tests__/) — 42 unit tests across 5 files covering all four collectors and the schema check module. Tests run offline against static HTML/JSON fixtures and require no network access or Firebase credentials.

    FileTestsCoverage
    schemaCheck.test.ts15All 4 pure check functions — valid data returns null, renamed/missing columns return correct alerts
    collect.iTrust.test.ts6API field mapping, MM/DD/YYYY → YYYY-MM-DD parsing, fetch-depth limiting, schema alert on field rename
    collect.htmlTable.test.ts10WHI + Zansec parsing from live fixture HTML, staleness-driven row depth, schema alert on column rename
    collect.orbit.test.ts5Livewire 3-step flow with stubbed responses, DD-MM-YYYY parsing, schema alert on column rename
    collect.uttamis.test.ts6Scheme name mapping, DD-MM-YYYY parsing, schema alert for unrecognised scheme, deduplication of repeated unknown names
  • HTML/JSON fixtures (functions/src/__tests__/fixtures/) — Five static fixtures seeded from live sources. Run node scripts/fetch-fixtures.mjs to refresh the WHI and Zansec fixtures when source sites change; Orbit is hand-crafted from the expected #historicalNAVTable structure.

  • scripts/fetch-fixtures.mjs — Helper script to re-fetch WHI and Zansec HTML fixtures from the live sites.

Changed

  • functions/package.json — Added "test": "vitest run" script; ESLint --ignore-pattern src/__tests__ to exclude test files from the lint pass
  • functions/tsconfig.json — Excludes src/__tests__ so tsc --noEmit doesn't apply noUnusedLocals to test files
  • functions/vitest.config.ts — Minimal Vitest config targeting src/__tests__/**/*.test.ts

2026-04-22

Schema Change Alerting & Pipeline Diagnostics

Added

  • Schema change detection (functions/src/schemaCheck.ts) — New module with pure validation functions for each data source. Checks every collector run against configured column/field expectations and emits structured SchemaAlert objects (fund_id, collector, missing, actual, detected_at).

    Collectors covered:

    • iTrust — validates JSON field names in API response (checkITrustRecord)
    • HTML Table funds (WHI, Zansec) — validates all configured column titles against actual table headers; previously only threw on a missing date column and silently produced nulls for everything else (checkHtmlTableHeaders)
    • Orbit — validates #historicalNAVTable column headers after Livewire interaction (checkOrbitHeaders)
    • UTT AMIS — detects unrecognised scheme names, meaning a new or renamed UTT fund has appeared (checkUttSchemeNames)
  • Immediate schema alert email (functions/src/reports.dailyEmail.ts: sendSchemaChangeAlert) — If any schema change is detected during a pipeline run, an alert email fires within that same run rather than waiting for the 8am digest. Subject line: 🔴 NavViewTZ: Schema Change Detected — [fund_ids]. Body lists each affected fund, the missing columns, the actual columns found, and detection timestamp.

  • Schema changes section in the daily digest — The morning QA email now includes a 🔴 Schema Changes Detected block when alerts were logged in the last 24 h. Schema alerts also flip the overall status indicator from green to red.

  • schemaAlerts field in pipeline logs — Each /pipeline_logs/{id} document now stores a schemaAlerts array alongside errors, enabling historical audit of when schema changes first appeared.

  • Diagnostic scripts (scripts/) — Two standalone Node 22 ESM scripts for manual health checks (no build step required):

    • spot-check.mjs — Fetches live data from the iTrust API and Zansec HTML table, validates source schema and parse quality end-to-end
    • firestore-check.mjs — Connects to Firestore via service account key and audits snapshot coverage, date ranges, null fields on recent rows, and date gaps per fund

Changed

  • All four collector functions (collectITrust, collectUTTAMIS, collectHtmlTable, collectOrbit) now return CollectorResult { snapshots, schemaAlerts } instead of plain RawSnapshot[]
  • syncFundData now declares GMAIL_USER and GMAIL_APP_PASS secrets so it can send immediate schema alerts without depending on the dailyQAReport function

2026-04-22

Android Widget Support & Publish Lag Metrics

Added

  • Widget static metadata (functions/src/index.ts) — seedFundMetadata now includes five new fields per fund: fund_name_short, fund_manager_short, logo_key, color_primary, redemption_days. Enables the Android widget to render brand-correct UI from a single Firestore document read with no subcollection queries.

  • sparkline_7d (functions/src/enrich.performanceMetrics.ts) — 7-point native number[] array of the last 7 daily NAV values, oldest-first. Written to both /funds/{id} and /enriched/{date}. Distinct from sparkline_1y (50-point yearly overview stored as JSON string) — the 7-day version is a native Firestore array for direct Flutter List<double> consumption without JSON decoding.

  • Publish lag metrics (functions/src/enrich.performanceMetrics.ts) — Three new fields measuring time from NAV effective date @ 16:00 EAT to our scraper's collected_at, stored in hours (float):

    • latest_publish_lag — lag for the most recent snapshot
    • avg_publish_lag_7d — rolling mean of last 7 valid snapshots
    • avg_publish_lag_30d — rolling mean of last 30 valid snapshots

    Written to both /funds/{id} and /enriched/{date}. Null when fewer than 3 snapshots have a valid collected_at. Measures "time from market close to our awareness" — not the fund manager's actual publish time.

  • Android widget data reference (docs/widget_data_field_reference.md in NavViewTZ_Android) — Master reference document for the widget covering every required field, its Firestore source, Dart type, and whether it is read directly, computed on-device, or stored locally. Includes a complete FundWidgetModel.fromFirestore() factory.

Changed

  • seedFundMetadata manager names corrected to official legal names: "iTrust Finance Ltd", "UTT Asset Management and Investor Services PLC", "Orbit Securities Company Limited", "Watumishi Housing Investments", "Zan Securities Ltd".
  • Brand colours updated to match official fund family branding.

Fixed

  • sparkline_7d missing from enriched archive write — sparkline_7d was computed and written to the top-level fund doc in commit b7aab9b but omitted from the /enriched/{date} subcollection write. Fixed in 3c03467.

Unreleased

In Development:

  • Frontend confidence badges — inline ~ or colour dot (EXACT/APPROXIMATE/STALE) per return figure
  • Frontend tooltip/detail layer — surface actual_days, nav_start, nav_end, canonical_start, confidence per metric; use STALE to show "⚠ based on NAV 6 days apart — holiday period"
  • /methodology page linking to docs/metrics-methodology.md
  • Comparison matrix updated to use ret_*_ear fields explicitly

2026-04-20

Functional Design Token Architecture v0.2.0

Key Changes

  • Systematic Theming Migration — Converted hundreds of hardcoded Tailwind color scales and inline hex codes into a unified set of functional CSS variables (surface-base, content-primary, primary, success, danger, etc.).
  • Unified Dark Mode — Achieved full visual consistency across light and dark modes by mapping all components to a single source of truth in base.css.
  • Refactored Views & Components — Systematically migrated core views (Analysis, Reports, Subscriptions, Developer Tools) and specialized data displays (Comparison Matrix, Growth Tables, Accumulation Summary).

Technical Polish

  • Lean Design Tokens — Purged legacy Vue/Vite boilerplate variables, resulting in a minimal and purpose-built :root config.
  • Fixed Semantic Mismatches — Resolved structural issues where components used incorrect variables, ensuring the design system flows correctly from the root.

2026-04-19

NAV Lookup Methodology Overhaul & Enriched History

Changed

  • d1 Previous Trading Day Strategy (enrich.performanceMetrics.ts/js) — d1 no longer targets ago(1) as a calendar date. It now finds the second-most-recent NAV by index — the actual previous trading day regardless of calendar distance. After a Thu+Fri holiday + weekend, Monday correctly compares against Wednesday, not a failed lookup against Friday.

  • Period-aware tolerance — replaced the flat T=3 tolerance applied to all periods with a documented per-period config PERIOD_TOLERANCE_DAYS (w1=5, m1=7, m3=10, ytd=12, y1=14). Each value is justified against the worst-case Tanzania holiday gap (Thu+Fri+Sat+Sun = 4 days) with explicit reasoning in code.

  • STALE confidence level — no longer hard-refusing calculations beyond tolerance. NAVs found beyond PERIOD_TOLERANCE_DAYS but within a 30-day hard cap are calculated and flagged STALE rather than discarded. Confidence ladder is now EXACT → APPROXIMATE → STALE → INSUFFICIENT.

  • prevCanonicalWorkingDay — new helper that returns the last Mon–Fri calendar day, used as the d1 canonical target for documentation and metrics_meta. Explicitly marked as the future hook for prevPublicTanzaniaWorkingDay once a Tanzania public holiday calendar is available.

  • metrics_meta schema extended — d1 now includes canonical_start (the prevCanonicalWorkingDay result) alongside start, end, days, nav_s, nav_e, conf. Frontend can use the gap between canonical_start and start to explain holiday-driven confidence downgrades.

  • Weekend cron (functions/src/index.ts) — scheduler extended from Mon–Fri (0 */3 * * 1-5) to daily (0 */3 * * *). Weekend runs exit cheaply when no new snapshots are found; enrichment is gated on transformAndPersist returning new data.

  • Both enrich.performanceMetrics.ts (Cloud Functions) and enrich.performanceMetrics.js (Apps Script) kept in full sync throughout.

Added

  • Enriched metrics history subcollection — enrichAllFunds now writes to /funds/{id}/enriched/{ret_as_of} in addition to the existing /funds/{id} merge. Doc ID = consensus date → idempotent. Stores all computed metrics + enriched_at timestamp. Static fund metadata excluded (lives on the parent doc). Enables metric trend tracking, retroactive NAV audit, and future historical views.

  • EnrichedMetricsSnapshot type — TypeScript interface for the historical enriched record.

  • Firestore indexes (firestore.indexes.json) — two composite indexes for the new enriched subcollection: per-fund date-descending, and cross-fund by consensus_date + freshness_status.


2026-04-18

Build Fixes & Vercel Deployment

Fixed

  • getPeriodExplanation missing export (src/utils/interestRates.ts) — function was imported by DevInterestRatesView.vue but never exported; added implementation mapping each Period to a human-readable description.
  • Firebase package corruption — node_modules/firebase/ directory existed without a package.json, causing TypeScript to fail on firebase/firestore and firebase/auth subpath imports. Resolved via clean reinstall.
  • DevInterestRatesView.vue stale type references — view was using removed/renamed ReturnResult properties (isValid, dataPoints, latestDate, referenceDate); updated to current fields (value !== null, actualDays, endDate, startDate). Also typed periodLabels as Partial<Record<Period, string>> since the 3m period is intentionally excluded from this view.
  • firestoreService.ts non-null narrowing — snapshotSnap.docs[0] flagged as possibly undefined despite !empty guard; added non-null assertions at two call sites.
  • Email log target sheet (appscripts/lib.emailLog.gs) — logEmailBatch_() was incorrectly writing to the History spreadsheet; corrected to write to the Email List spreadsheet.

Deployment

  • Vercel preview deployed — First successful Vercel build after resolving all TypeScript and rollup errors above.

2026-04-18

Premium Branding & Logo Refinement

Added

  • New App Branding Assets (public/logo.png, public/logo-dark.png, public/logo-white.png) — Deployed premium high-resolution graphics for the NavViewTZ identity.
  • Theme-Aware Sidebar Logos (src/components/Sidebar.vue) — Implemented dynamic logo switching: dark graphic for light mode, white graphic for dark mode.

Fixed

  • Logo Transparency — Applied surgical pixel-level alpha channel correction to logo.png and all derived favicons to remove "fake" white corners and ensure clean rendering on any background.

Changed

  • Favicon Ecosystem — Re-generated all 7 standard favicon assets (.ico, 32x32, 16x16, apple-touch, android-chrome) from the new branded and corrected source.
  • Metadata & SEO — Updated index.html meta tags (og:image, twitter:image, og:title) to use the new branding and title consistency.
  • Sidebar Cleanup — Deprecated and removed the legacy vector-based IconTanzania.vue component in favor of the new branded raster assets.

2026-04-18

Dual-Track Returns, Email Corrections & Infrastructure

Fixed

  • EAR mislabeling bug — ret_1w (and all ret_* fields) were storing Effective Annual Rates since the April 14 metrics rewrite, causing the weekly email to display annualized figures (e.g. +105.76%) labeled as weekly returns. Root cause: safeVal_() / safeVal() always extracted .value (EAR) not .simple.

Changed

  • Dual-track return storage (appscripts/enrich.performanceMetrics.js, functions/src/enrich.performanceMetrics.ts) — ret_1d/1w/1m/3m/ytd/1y now store simple returns (actual period gain). Six new ret_*_ear fields store the annualized equivalents for cross-period comparison. Both Apps Script and Firebase Functions enrichment updated in sync.

  • Performance Heatmap (src/components/dashboard/PerformanceHeatmap.vue) — switched to ret_*_ear fields since it displays all periods side-by-side. Column headers now explicitly labeled p.a. All other components (LatestFundData, DailyPulse, DeepInsightCards, PulseHero, WatchlistWidget) continue using simple ret_* fields.

  • Weekly email template (appscripts/reports.performance.gs):

    • Removed "Here's the new spiced up snapshot" greeting line
    • AI summary markdown (**bold**, *italic*) now stripped and converted to HTML before rendering — no more raw asterisks visible to readers
    • Replaced purple left-border blockquote with a clean neutral card
    • Footer updated: removed "NavViewTZ Pipeline Agent" line, fixed broken href="#" unsubscribe, added "View Report Online" link, split brand line into two lines
  • Snapshot fund names — formatFundName_() was incorrectly applied at snapshot save time, truncating names (e.g. "Inuka Dozen Index Fund" → "Inukadozen"). Now applied only at email render time; full names saved to sheets.

Added

  • ret_3m and ret_*_ear type fields added to EnrichedFundData interfaces in firestoreService.ts and fundDataService.ts

  • Email send logging (appscripts/lib.emailLog.gs) — logEmailBatch_() appends one row per send batch to the History spreadsheet: sent_emails for production, sent_as_test for test runs. Captures timestamp, type, report_id, subject, sent_count, failed_count, is_test. Wired into sendWeeklyPulseReport, testWeeklyReport, and sendCorrection_.

  • One-off correction email (appscripts/reports.correction.gs) — sendCorrectionEmail() / sendCorrectionEmailTest() sends a branded apology to subscribers explaining the EAR mislabeling, showing corrected figures for 2026-W16, and outlining what was fixed.


2026-04-18

Firebase Backend: Daily QA Report & Frontend Lint

Added

  • Daily QA Email Report (functions/src/reports.dailyEmail.ts) — Ports reports.dataQuality.gs from App Script to Firebase Cloud Functions. A new dailyQAReport scheduled function fires at 8am EAT, Mon–Fri and sends a rich HTML digest email covering: 24h pipeline run count, snapshots written, updated funds, stale funds (>3 days), and any collector errors. Email is sent via Gmail SMTP using secrets stored in Firebase Secret Manager (GMAIL_USER, GMAIL_APP_PASS).

  • Pipeline Run Logging — syncFundData now writes a pipeline_logs Firestore doc after every run ({timestamp, collected, persisted, errors[]}). This is the data source for the daily report, replacing the App Script QA sheet.

  • App Script → Firebase Feature Audit (docs/appscript-vs-firebase-audit.md) — Comprehensive mapping of every App Script function against its Firebase equivalent, with status (ported / partial / not ported), rationale for gaps, and open questions.

  • Stage 3 Validation Script (scripts/validate-stage3.mjs) — CLI utility to compare live Firestore NAV data against the Apps Script timeSeries endpoint for all 16 funds. Used to verify migration correctness after backfills.

Fixed

  • Frontend ESLint — Resolved all 24 lint errors across 6 Vue components:
    • DailyPulse.vue — removed unused t and dead format() helper
    • Sidebar.vue — added defineOptions({ name: 'AppSidebar' }) for multi-word component rule
    • ComparisonMatrixView.vue — dropped unused Filter import; replaced any with Record<string, unknown> in sort, formatValue, and getRiskLevel
    • GoalsView.vue — removed unused computed, PinOff import, and formatDate
    • ReportSnapshotView.vue — removed unused getFundIcon function
    • WeeklyCheckupView.vue — removed unused ChevronRight and t; replaced catch (e: any) with unknown + instanceof Error guard

Infrastructure

  • Default shell set to Bash — Firebase deploys must be run from Git Bash (not PowerShell) due to PowerShell's heavier startup overhead exceeding the Firebase CLI's 10s local analysis timeout.
  • nodemailer lazy-loaded inside sendDailyQAReport (not at module init) to keep function cold-start time under the CLI analysis threshold.

Version History

v0.1.4 — Mobile Layout Fixes

Bug Fixes:

  • Subscribe Form — Name fields now stack vertically on mobile instead of being squeezed side-by-side
  • Weekly Reports Table — Responsive layout with hidden columns on mobile, dark mode support

v0.1.3 — Export Improvements

Improvements:

  • Responsive Export Footer — PNG exports now use a two-line footer on mobile devices:
    • Line 1: Title and subtitle centered
    • Line 2: NavViewTZ branding (left), timestamp and logo (right)
  • Prevents footer clutter on small screen exports

v0.1.2 — Mobile-First Optimization

New Features:

  • Scroll-to-Hide Hamburger — Menu button hides when scrolling down, reappears on scroll up (iOS-style)
  • Safe Area Padding — Proper bottom padding for iOS devices using env(safe-area-inset-bottom)

Improvements:

  • Mobile Layouts — Stacked layouts for Analysis, Calculator, and Compare views on small screens
  • Responsive Charts — Adaptive chart heights (300px mobile, 450px desktop)
  • View Controls — Icon-only mode on mobile with horizontal scroll
  • Footer Redesign — Links now stack vertically and center-align on mobile
  • Sidebar UX — Full text labels on mobile, X button repositioned to right, logo sizing fixed

Technical:

  • Reduced chart container min-heights for portrait orientation
  • Added responsive padding patterns (p-4 lg:p-6)
  • Improved PulseHero and PerformanceHeatmap header layouts

v0.1.1 — Calculator Enhancements

New Features:

  • Contribution Extrapolator — 3-band future projections (Optimistic, Neutral, Pessimistic)
  • Tabulated Investment Growth — Data tables with Past, Future, and Combined tabs
  • Modular Architecture — Compartmentalized Calculator into reusable components

Improvements:

  • PNG exports now capture full component (headers, cards, selectors, charts)
  • Added componentRef pattern for element-based exports

Bug Fixes:

  • Fixed watermark URL from navviewtz.vercel.app to nav-view-tz.vercel.app

v0.1.0 — Initial Release

Core Features:

  • Executive Dashboard with real-time KPIs
  • Momentum Pulse for top/bottom movers (1D/1W/1M/1Y)
  • Deep Analysis with historical performance charts
  • Return Comparator for side-by-side fund comparison
  • Investment Calculator for wealth simulation
  • Weekly Reports archive

Last updated: April 23, 2026

Built with ❤️ for Tanzania's investment community

Build: f40d755