Kumbukumbu ya Mabadiliko
Fuatilia jinsi NavViewTZ inavyobadilika
Maelezo ya matoleo yameandikwa kwa Kiingereza.
Una wazo?
Saidia kuunda mustakabali wa NavViewTZ
2026-10-11v0.15.1: Fund Snippets Without Placeholders, and a Daily Pulse That Knows What Day It Is
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, andcheck:prerendered-seofails 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-liquidandutt-umojahave 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-inukadozenreads "Inuka Dozen Fund NAV TZS 130.20";/funds/utt-jikimu,/funds/utt-watoto,/funds/utt-wekezaand/sw/funds/utt-jikimucarry "UTT"; and no live fund page title contains "—".
2026-10-08v0.15.0: One Design System, and Phones First
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/designrenders every component in every state, in light and dark.docs/design-system.mdis 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,UiMenuandUiSheet: 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 frommd(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/10work. - 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, andGoalTrajectory.vue(replaced byGoalCard.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-07v0.14.0: Subscriber Emails Get a Proper Look
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_contentper 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-Unsubscribeheader 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), directnavviewtz.comlinks 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-28v0.13.0: /sw/ Is Swahili All the Way Down
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 offunds_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(inprebuild): lists displayed strings with no Swahili entry and entries whose source text is gone. Warns by default;--strictfails.- 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 markedlang="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 Englishmeta.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 NaNon 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-27v0.12.0: Fund Pages Answer the Search, and a Weekly Growth Lab to Prove It
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'sstate.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 gitignoreddocs/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
errorto its ownhistoryError, which the Analysis chart panel now shows.
2026-09-22v0.11.0: Insights Gets a Real Home, and Two Funds Get Their Real Category
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
/insightsnamespace./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 byroute.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.Imaanis 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-categoryinvestmentCategory(), which silently mis-bucketedalpha.halal("Shariah Multi-Asset") intoothervia a literal/equity/regex that didn't match "Equities". Every fund now gets aprimaryCategory(asset class) plus zero or morecrossCuttingTags(currently justshariah, sourced from the CSV'sESG Policycolumn).- "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.Imaanwas misclassified as Fixed-Income (both infunds_overview.csv'sStructurecolumn and in the live Firestoretypefield). 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.iSavewas 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: Firestoretypesaid "Equity"; both are balanced funds per their offer documents and the CSV.alpha.halal: Firestoretypesaid "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-v2offer-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 fororbit.inukaand 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 fullvite-ssgproduction 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.seedFundMetadatadeployed and re-invoked against production Firestore. Confirmed live onnavviewtz.com/funds/alpha-halal, which now reads "Multi-Asset" instead of "Balanced" (itrust.Imaan's owntypewas 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-18v0.10.0: Onboarding Is Now a Tour of the Dashboard
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 inHomeViewonly. 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-autoclips a ring on the inner element on every side but the bottom. Anchors aredata-tourattributes 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. Belowmdthe 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/:idsees 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 isonboarding_dismissed { via: navigate, target }. - Swahili copy for all three steps (
onboarding.tour.*insw.json).
Removed
OnboardingOverlay.vueand its App-level mount, with theonboarding.welcome*/spotlight*locale keys. The seen-flag and store are unchanged, so nobody who dismissed the modal sees the tour; the Playwright specs that seednavview_onboarding_seenkeep 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-liquidlanding sends nothing and the tour starts on the next click to Dashboard; a returning visitor sees nothing.
2026-09-18Hydration Spec: Admin Link Broke Every Route on Localhost
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.vuedecidedshowAdminLinkfromisLocalhostHost()during setup; that isfalsewith nowindow(SSG) andtrueon the client's first render at127.0.0.1:4173, so the client tree had an extra<a>the HTML did not — Vue'sHydration node mismatch: rendered on server: <text>, expected on client: aunderHYDRATION_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.vuehad the same pattern and gets the same gate, withisLocalhostadded to its load watcher so the localhost fetch still fires once the flag flips.
2026-09-18v0.9.2: Onboarding Baseline
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.vuenow firesonboarding_shownwhen it actually renders,onboarding_step { step }on every advance, andonboarding_dismissed { step, via, target }on exit, whereviais one ofskip(the ×),backdrop,done(the final button) ornavigate(a spotlight tile —targetis its path).stepis 1-based so it reads as the visitor saw it. A closed tab sends nothing: abandonment is the gap between shown and dismissed. GA4 attachespage_locationto 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 inTRACKED_EVENTSso/adminand 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 exposesresolved— 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,viaandtargetas event-scoped custom dimensions (stepas a dimension, not a metric — it is a funnel breakdown, not something to sum).
2026-09-17v0.9.1: GA4 Had Never Received a Hit
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
gtagstub pushed an Array; gtag.js only executesarguments.src/lib/analytics.tsbootstrappedwindow.gtagasfunction 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 anargumentsobject (what Google's own snippet pushes) is dispatched as aconfig/eventcommand. So the container loaded, every command was dropped, no_gacookie was ever set and no hit was ever sent. The stub now pushesarguments. Verified against the live container:_gaand_ga_D32E91F4DGcookies set, and a batched POST togoogle-analytics.com/g/collectcarryingfund_viewedwithep.fund_id,ep.fund_type,ep.managerplus thepage_viewwith 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_viewon everypushStatealongside 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-17v0.9.0: GA4 Sensors Where the Traffic Is
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 inFundDetailView;dashboard_view_changed {view},dashboard_sorted {surface, key, order}(both the card sort and the table headers) anddashboard_exported {format, fund_count}on the home dashboard;language_changed {from, to}inuseLocale;not_found {page_path, referrer_host}on the 404 page so broken inbound links are countable. - Personalisation events —
login {method}andsign_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}andgoal_deleted {storage}. Goal names and amounts are the user's own figures and are never sent. exceptionsensor — GA4's recommended event, sent from the Vue app-levelerrorHandler,windowerrorandunhandledrejection({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 ofprebuild) — scanssrc/for everytrackEvent('…')call andv-analyticsdirective and fails the build when a name is missing fromTRACKED_EVENTSinfunctions/src/ga4.ts. The drift that hid the funnel cannot recur silently.
Changed
functions/src/ga4.tsTRACKED_EVENTSnow lists all 27 custom events, grouped by surface, so/adminand the weekly pulse report the whole funnel:subscribe_pending_confirmation,subscribe_confirm_failed, both post-subscribe CTA clicks,feedback_submittedandfeedback_failedwere all previously dropped by theinListFilter.
Fixed
page_viewcarried the previous page's title, and was lost in hidden tabs.installAnalyticssent the hit from arequestAnimationFrameafterrouter.afterEach.@unhead/vuewritesdocument.titlefrom asetTimeout(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'sdom:renderedhook (with a one-second fallback), andmain.tspasses the head client through for that.- Comparison events no longer fire on page load.
comparison_fund_selected,comparison_window_changedandcomparison_normalize_toggledwere emitted fromwatch()ers on the refs, andapplyComparisonQuerysets 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-08Admin Analytics: One Gate, Server-Side
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 anAuthorization: Bearer <id token>header, verifies it with the Admin SDK'sverifyIdToken, and checks the decodedemailclaim againstADMIN_EMAILSbefore reading a single document. Everyone else gets a bare403 {"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.vueat/admin— subscriber status counts, the active list, confirmation rate, recent signups withsignup_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 andSidebar.vue), shown only to the operator, so/adminno 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— extractedgetGa4Stats(propertyId, days);getWeeklyGa4Statsnow delegates to it with 7. No call-site changes.firebase-adminpromoted from a transitive dependency to a declared one, and marked--externalin the esbuild bundle rather than inlined./adminand/sw/adminare noindex —useSeo.tsemitsnoindex, nofollowandvercel.jsonsets the matchingX-Robots-Tag, the way/goals,/portfolioand/settingsare 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-08Signup Classifier: Stop Mailing the Victims
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_subscriberswith 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-08Subscriber Archival: Spam and Expired-Pending Collections
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 reachedpendingunder double opt-in and matched the injection signature.purgeExpiredPendingSubscribers(functions/src/subscriberArchive.ts) — daily at 04:15 EAT, archivespendingsignups 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 leavespendingon its own.scripts/migrate-spam-subscribers.mjs— dry-run by default;--applyto write,--also-injected=to relabel named addresses not yet markedinjected.- 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-08Subscription Integrity: Double Opt-In & Provenance
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):subscribenow writesstatus: "pending"with a random 32-byte token and a 7-day expiry, and sends only a confirmation email. A newconfirmmethod validates the token and is the sole path tostatus: "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 fromsubscribed_at. /confirmroute (src/views/ConfirmSubscriptionView.vue): verifies the emailed token and carries the "You're In!" celebration that previously fired on form submit. Markednoindex, followalongside/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 asREVIEW, and flipsstatusrather 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.tspattern. A filledwebsitefield returns a normal success response so a scripted submitter gets no signal to adapt to. - Origin allowlist:
subscriberequires an allowlistedOrigin(browsers always send one on a cross-origin POST).confirm/unsubscribeare exempt as top-level navigations from email clients. CORS reflects only allowlisted origins instead of*. unsubscribeno longer discloses whether an address is on the list, andsubscribereturns 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).
getTransporteruses a dynamic import rather thanrequire, 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 leftactivepending manual review. The genuine confirmed list is 5 subscribers.
2026-09-08Subscriber Visibility & Weekly Engagement Reporting
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 newscripts/notify-engagement-report.mjscomposes 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. Seedocs/site-manager-runbook.md§3b anddocs/site-manager.md(Stage 6, now shipped).
2026-09-01Mobile Navigation Fixes
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 newmeta.showBackButtonflag 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, andUnsubscribeView.vueeach had their own "Back" link plus abg-surface-mutedfull-page background — both left over from when these pages had no shared layout at all. Removed both, along with themin-h-screenvertical-centering wrapper that was pushing content far down the page under the new header.PublicPolicyView.vue(Privacy/Terms/Support) andSourcesView.vuehad 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-domainon sign-up is a Firebase Console authorized-domains configuration item, not a code fix — left for the owner.
2026-09-01Fund Intelligence v2 Migration & Governance Sections
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/managersJSON record.src/data/fundIntelligence.tsandfunctions/src/fundIntelligence.tsload these and thread them throughfundPresentation.ts'smapToFund();publicExports.tsandscripts/build-field-provenance.mjsread 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 newgovernanceFacts()helper infundPresentation.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), andfunds_overview.csvall read the verified manager-level value instead.
2026-08-31Fund Page Accuracy & Discoverability
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()'sdataAsOfnow normalizes the same waygenerate-sitemap.mjscomputeslastmod, so the two can never disagree;check-prerendered-seo.mjsasserts they match. - Fund and manager pages genuinely translated in Swahili (#113) —
FundDetailView.vueandManagerDetailView.vuehad almost not()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 infundPresentation.ts, picked up automatically byFundCard.vueand both detail views.
Fixed
vercel.appalias no longer indexable — the production deployment alias (nav-view-tz.vercel.app) served the full site alongside the canonicalwww.navviewtz.comdomain, 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-29First-Run Onboarding & Account Settings
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. /settingspage: profile editing (display name, via a newauthStore.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.tscaptured Pinia state by reference instead of a snapshot inrouter.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-28Comparator: Growth Metrics Matrix
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 — labelledSelected (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-28Sidebar Cleanup
Sidebar Cleanup
Removed
- Manual "Refresh Data" sidebar button (
src/components/Sidebar.vue) — redundant with the automatic refreshApp.vuealready triggers (fundsStore.refresh(true)); removed the button, itsisRefreshingspin state, and the now-unuseduseFundsStoreimport from the sidebar. The store's ownrefresh()method is untouched — only the manual UI trigger is gone.
2026-08-27WebMCP Surface and Firestore Fallbacks
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 intoonMounted. 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.icashwhile the Firestore document isitrust.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 topublic/data/history/duringprebuild, andfetchLatestfalls back to the existingpublic/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-idsinprebuild(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-testingin Chrome 151; without itdocument.modelContextis undefined and no tool registers. Tool behaviour is covered against an injected harness intests/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-22Monthly Digest: Fix Blank Pipeline Activity
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'scount()/sum()aggregate query overpipeline_logs(filtered bytimestamprange) was throwingFAILED_PRECONDITION: The query requires an indexon every run. The surroundingtry/catchswallowed 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
sectionErrorson 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," andtriggerMonthlyDigest's JSON response includes the real error — this is what made the missing index diagnosable in minutes instead of requiring log archaeology.
2026-08-12v0.4.1 MCP Guardrail Fixes from Live ChatGPT Evaluation
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 viaclient.getInstructions()) telling the model to always call a tool for current figures, resolvefund_ids fromget_market_briefing/search_fundsbefore callingcompare_funds, never callcompare_fundsmore 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 returnsstructuredContent: {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_fundsincompatible-comparison warning — both thestructuredContent.warningand 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) — strengthenedget_market_briefing,search_funds, andcompare_fundswith 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 chainingcompare_fundscalls across a single request, and an explicit no-recommendation instruction for thecomparable: falsecase.
2026-08-11v0.4.0 Public MCP Server for ChatGPT & Agents
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
navViewMcpFirebase 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 cachedpublic_exports/latest_fundsFirestore 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.mjsanddocs/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 outperformance_basis/headline_metricfor 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 Firestoremerge: truewrite actually preserved. AddedexistingHeadlineFields()to carry those fields forward explicitly, with unit coverage./mcprouting — the SPA's Edge Middleware (middleware.ts) was intercepting/mcpand returning its own 404 before thevercel.jsonrewrite to the Firebase function could run;/mcpis now excluded from the middleware's matcher.
2026-07-26Indexable Swahili Pages (closes #56)
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
/swlocale tree - every public page is mirrored at a/swprefix (/sw,/sw/compare,/sw/funds/utt-liquid,/sw/managers/itrust-finance). Routes are declared once and mirrored programmatically insrc/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,mapatofor NAV return andmazaofor published yield,kitengo,ada,ukwasi, NAV kept as-is. hreflangannotations - every page emits reciprocalen,sw, andx-defaultalternates server-side, andpublic/sitemap.xmlcarries the matchingxhtml:linkblocks (88 URLs across 2 locales).- Locale-aware regression checks -
check-seo-middleware.mjsnow asserts Swahili rendering,lang="sw", prefix-internal links, Swahili 404s, and hreflang reciprocity in both the responses and the sitemap.
Changed
- Locale resolution - a
/swURL now outrankslocalStorageand 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
structurefield, fixing the ungrammatical "as a Open-end Money Market" in English and giving Swahili a translated noun phrase.
2026-06-28v0.3.2 Phase 3 Technical Maturity Completed
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-28Optional GA4 Interaction Tracking (closes roadmap §3.5 Interaction Tracking)
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 exposestrackEvent()plus av-analyticsdirective 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_IDdeployment setting and the privacy boundary for tracked events.
All notable updates and features for NavViewTZ are documented here.
2026-06-21Reporting Overhaul: Monthly Digest, Source Uptime Metrics & Distinct Senders
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 fromrunIncidentCheck, so no extra pipeline work. Unit-tested insrc/__tests__/sourceHealth.test.ts. - Monthly Digest report (
functions/src/reports.monthlyDigest.ts) — NewmonthlyDigestReportscheduled 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 atriggerMonthlyDigestHTTPS 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) to0 7 * * *(7am, every day) so weekend NAVs are reported on. - Distinct email senders (
functions/src/config.tsMAIL_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-20In-App Feedback Link (closes roadmap §3.5 Feedback Mechanism)
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 exportsFEEDBACK_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 lucideMessageSquarePlusicon 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 inlineFEATURE_REQUEST_FORM_URLconstant to importFEEDBACK_FORM_URLfrom@/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-8togap-6withflex-wrapso the new entry lays out cleanly alongside Subscribe / WhatsApp / Methodology / About across the responsive breakpoints.
Verified
npm run buildpasses — no new warnings.
2026-06-20v0.3.1 Sticky Mobile Header & Managers Catalogue Polish
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 ausePageTitlecomposable 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
/managerspage 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.jsondefining 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-20to render beneath the sidebar menu.
2026-06-20Per-Fund Enrichment Dirty Flag (closes roadmap §3.3)
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 fortriggerEnrichmentandcleanAndRebackfillUTT).classifyFundForEnrichment(...)— pure helper exporting thefull | lightdecision 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
advancedFundsget 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_summarywith 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
syncFundDatapipeline (functions/src/index.ts) — when enrichment runs, passesadvancedFunds = new Set(persistedFunds)so only funds that actually persisted new rows trigger a full recompute. The outerhasAdvancedgate (skip enrichment entirely when zero funds advanced) is unchanged.enrichAllFundslog lines now report the full vs. light split each run, so steady-state reduction is visible in Cloud Logging without bespoke metrics./meta/fund_summarycorrectness 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 testinfunctions/— 115 / 115 tests pass (12 new partition tests, 14 existing calendar tests, 89 pre-existing).npm run buildinfunctions/— esbuild bundle compiles cleanly (157.2 kb).- Pre-existing
reports.weeklyPulse.ts:131typecheck failure (unrelatedArray.prototype.atlib target) is unchanged.
2026-06-20Tanzania Holiday Calendar for d1 Metrics
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_HOLIDAYSset (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
calcD1Returncanonical_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.tsupdated to reference the new helper and the gazetted calendar; the "future hook" wording removed. PERIOD_TOLERANCE_DAYSjustification 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 testinfunctions/— 103 / 103 tests pass (including the 14 new calendar tests).- Pre-existing typecheck failure in
reports.weeklyPulse.ts:131is unrelated to this change.
2026-06-20v0.3.0 Managers Catalogue, Weekly Reports Redesign & Sorting Controls
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 inpublic/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-18v0.2.2 Fund Pages & Discovery
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.txtandpublic/sitemap.xmlwith 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.txtandsitemap.xmlfrom the SPA catch-all rewrite so crawlers can fetch them directly.
2026-06-18Fund Logo Fallback & Layout Polish
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
gradientStyleinFundLogo.vueto generate high-quality, professional linear HSL gradients deterministically from the fund name or ID.
Changed
FundLogo.vuefallback hierarchy — Updated component logic:- Try loading specific fund logo (
/logos/<fundId>.png). - Fall back to manager logo (
/logos/<providerId>.png) with a small initials badge at the bottom-right. - Fall back to full-container initials with a modern gradient background if the manager logo is also missing.
- Try loading specific fund logo (
- Initials deduplication — Added a explicit initials lookup map (
FUND_INITIALS_MAP) to prevent identical initials for different funds (e.g.ZIfor 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-8like theh-5Select 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-16Orbit Collector: Drop Livewire, Use Static Panels
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 itsdata-history-panel="<name>"marker up to the nextdata-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 theCOLLECTION FAILEDalert, so the layout canary will fire when a panel reappears with a different structure. spot-check.mjs— Orbit checks now probe for the twodata-history-panelmarkers 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 FAILEDalert contract it introduced is preserved on the missing-panel branches.
2026-06-15Source-Change Monitoring & Orbit Collector Hardening
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 immediateSchemaAlert, 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 explicitCOLLECTION FAILEDalert is raised when no table can be found. Whatever recognised columns are present (at minimumDATE+Net Asset Value) are ingested rather than dropping the fund entirely.CollectorResult— Gained an optionalfingerprintsmap (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
persistedFundstracking in pipeline logs — ThesyncFundDatapipeline 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
persistedFundsdata 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 leveragepersistedFundsfor the "Funds Updated" table in the daily email digest.
2026-04-30Functions Audit & Maintenance Documentation
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/.
- Fetches pipeline run success rates from Firestore
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-23Scraper Reliability Fixes: fetchWithRetry & Orbit CSRF
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 infetchWithRetry(Critical, affects ALL scrapers)The
AbortControllerand 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+setTimeoutper attempt, andclearTimeoutin both success and error paths.Before After 1 AbortController for all 4 attempts 1 AbortController per attempt Timeout fires once, kills all retries Timeout scoped to each attempt Timer leaked on catch Timer cleared on catch collect.orbit.ts— CSRF token never sent to Laravel (Orbit-only, caused HTTP 419)buildHeaders()setX-Livewire,Content-Type,User-Agent,Referer, andCookie— but never setX-CSRF-Token. ThenlivewirePost()tried to readheaders["X-CSRF-Token"]for the request body_tokenfield, which was alwaysundefined, falling back toextractTokenFromSnapshot()which returned the Livewire snapshot checksum — not the Laravel CSRF token. Laravel correctly rejected every POST with HTTP 419 "Page Expired".Fix:
buildHeaders()now includes"X-CSRF-Token": csrfin the returned headers objectlivewirePost()now receivescsrfas a direct parameter and uses it for the body_tokenfield, 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.tzandorbit.co.tzresponding normally
Lengo Kuu
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-23Orbit Collector Fix, Backfill & Gap-Aware Staleness
Orbit Collector Fix, Backfill & Gap-Aware Staleness
Fixed
collect.orbit.tsbroken Livewire component — Orbit split their page into two components:net-asset-value(live summary) andhistorical-net-asset-value(historical table). The collector was callingtoggleTableon the wrong component, causing HTTP 500 since at least Apr 21. Fixed by targetinghistorical-net-asset-valuefor the Livewire interaction. Collector is working again — no data loss on existing Firestore data.spot-check.mjssmoke check — updated to verifyhistorical-net-asset-valueinstead ofnet-asset-value, so a future component rename will be caught at deploy time.collect.orbit.test.tsfixtures — 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 fororbit.inukaand 31 fororbit.inukaDozen(AppScript-era single-row scraping holes and the recent Apr 21–22 collector outage).Gap-aware staleness in
staleness.ts—getStalenessMapnow scans the lastPIPELINE.GAP_SCAN_ROWS(60) snapshots per fund instead of just the latest one. If any consecutive gap exceedsPIPELINE.GAP_THRESHOLD_DAYS(5 days),daysStaleis bumped to cover back to the gap edge. This feedsderiveFetchLimitwhich then returns a deep enough window to fill the gap on the next collector run — self-healing without manual intervention.PIPELINE.GAP_SCAN_ROWS: 60added toconfig.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-23Stage 3 Migration Complete & Monthly Coverage Check
Stage 3 Migration Complete & Monthly Coverage Check
Migration: Stage 3 Complete
scripts/validate-stage3.mjs— fixed a field name mismatch: the validator was readingreturn_1m,return_3m,sparklinefrom Firestore but the enrichment function writesret_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 withlimit(35); no new Cloud Function required.
2026-04-23Firestore Audit Expanded to All 16 Funds
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 viaPromise.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.wekezaChange Detail Per-fund stale threshold WHI gets 7 days (government fund); all others 3 days Parallel Firestore reads count(), recent snapshots, and oldest-date queries run concurrently per fundExit code 1on any hard error (missing fund doc / empty subcollection);0on warnings-only or cleanSummary table Printed 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-23Live Smoke Pre-Deploy Gate
Live Smoke Pre-Deploy Gate
Added
scripts/spot-check.mjsexpanded to all 5 data sources — previously only covered iTrust and Zansec. Now verifies every upstream source before each deploy:Source Check iTrust (JSON API) All 6 expected fields present; dates parse as MM/DD/YYYY; core numeric fields non-nullZansec (HTML table) table#datatablepresent; all 6 expected column headers match; latest 3 rows parse cleanlyWHI (HTML table) table#example1present; 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; /navsAPI returns rows; at least onesnamemaps to a known scheme; latest date parses asDD-MM-YYYYOrbit (Livewire) Page loads; data-csrfattribute present;wire:snapshotfor thenet-asset-valuecomponent is presentExit code gate — script now exits
1on 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-22Fixture-Based Unit Tests for All Collectors
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.File Tests Coverage schemaCheck.test.ts15 All 4 pure check functions — valid data returns null, renamed/missing columns return correct alerts collect.iTrust.test.ts6 API field mapping, MM/DD/YYYY → YYYY-MM-DD parsing, fetch-depth limiting, schema alert on field rename collect.htmlTable.test.ts10 WHI + Zansec parsing from live fixture HTML, staleness-driven row depth, schema alert on column rename collect.orbit.test.ts5 Livewire 3-step flow with stubbed responses, DD-MM-YYYY parsing, schema alert on column rename collect.uttamis.test.ts6 Scheme 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. Runnode scripts/fetch-fixtures.mjsto refresh the WHI and Zansec fixtures when source sites change; Orbit is hand-crafted from the expected#historicalNAVTablestructure.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 passfunctions/tsconfig.json— Excludessrc/__tests__sotsc --noEmitdoesn't applynoUnusedLocalsto test filesfunctions/vitest.config.ts— Minimal Vitest config targetingsrc/__tests__/**/*.test.ts
2026-04-22Schema Change Alerting & Pipeline Diagnostics
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 structuredSchemaAlertobjects (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
#historicalNAVTablecolumn headers after Livewire interaction (checkOrbitHeaders) - UTT AMIS — detects unrecognised scheme names, meaning a new or renamed UTT fund has appeared (
checkUttSchemeNames)
- iTrust — validates JSON field names in API response (
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 Detectedblock when alerts were logged in the last 24 h. Schema alerts also flip the overall status indicator from green to red.schemaAlertsfield in pipeline logs — Each/pipeline_logs/{id}document now stores aschemaAlertsarray alongsideerrors, 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-endfirestore-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 returnCollectorResult { snapshots, schemaAlerts }instead of plainRawSnapshot[] syncFundDatanow declaresGMAIL_USERandGMAIL_APP_PASSsecrets so it can send immediate schema alerts without depending on thedailyQAReportfunction
2026-04-22Android Widget Support & Publish Lag Metrics
Android Widget Support & Publish Lag Metrics
Added
Widget static metadata (
functions/src/index.ts) —seedFundMetadatanow 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 nativenumber[]array of the last 7 daily NAV values, oldest-first. Written to both/funds/{id}and/enriched/{date}. Distinct fromsparkline_1y(50-point yearly overview stored as JSON string) — the 7-day version is a native Firestore array for direct FlutterList<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'scollected_at, stored in hours (float):latest_publish_lag— lag for the most recent snapshotavg_publish_lag_7d— rolling mean of last 7 valid snapshotsavg_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 validcollected_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.mdin 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 completeFundWidgetModel.fromFirestore()factory.
Changed
seedFundMetadatamanager 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_7dmissing from enriched archive write —sparkline_7dwas computed and written to the top-level fund doc in commitb7aab9bbut omitted from the/enriched/{date}subcollection write. Fixed in3c03467.
Bado Halijatolewa
Bado Halijatolewa
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; useSTALEto show "⚠ based on NAV 6 days apart — holiday period" /methodologypage linking todocs/metrics-methodology.md- Comparison matrix updated to use
ret_*_earfields explicitly
2026-04-20Functional Design Token Architecture v0.2.0
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
:rootconfig. - Fixed Semantic Mismatches — Resolved structural issues where components used incorrect variables, ensuring the design system flows correctly from the root.
2026-04-19NAV Lookup Methodology Overhaul & Enriched History
NAV Lookup Methodology Overhaul & Enriched History
Changed
d1 Previous Trading Day Strategy (
enrich.performanceMetrics.ts/js) — d1 no longer targetsago(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=3tolerance applied to all periods with a documented per-period configPERIOD_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.STALEconfidence level — no longer hard-refusing calculations beyond tolerance. NAVs found beyondPERIOD_TOLERANCE_DAYSbut within a 30-day hard cap are calculated and flaggedSTALErather than discarded. Confidence ladder is nowEXACT → APPROXIMATE → STALE → INSUFFICIENT.prevCanonicalWorkingDay— new helper that returns the last Mon–Fri calendar day, used as the d1 canonical target for documentation andmetrics_meta. Explicitly marked as the future hook forprevPublicTanzaniaWorkingDayonce a Tanzania public holiday calendar is available.metrics_metaschema extended — d1 now includescanonical_start(the prevCanonicalWorkingDay result) alongsidestart,end,days,nav_s,nav_e,conf. Frontend can use the gap betweencanonical_startandstartto 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 ontransformAndPersistreturning new data.Both
enrich.performanceMetrics.ts(Cloud Functions) andenrich.performanceMetrics.js(Apps Script) kept in full sync throughout.
Added
Enriched metrics history subcollection —
enrichAllFundsnow 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_attimestamp. Static fund metadata excluded (lives on the parent doc). Enables metric trend tracking, retroactive NAV audit, and future historical views.EnrichedMetricsSnapshottype — TypeScript interface for the historical enriched record.Firestore indexes (
firestore.indexes.json) — two composite indexes for the newenrichedsubcollection: per-fund date-descending, and cross-fund byconsensus_date+freshness_status.
2026-04-18Build Fixes & Vercel Deployment
Build Fixes & Vercel Deployment
Fixed
getPeriodExplanationmissing export (src/utils/interestRates.ts) — function was imported byDevInterestRatesView.vuebut never exported; added implementation mapping eachPeriodto a human-readable description.- Firebase package corruption —
node_modules/firebase/directory existed without apackage.json, causing TypeScript to fail onfirebase/firestoreandfirebase/authsubpath imports. Resolved via clean reinstall. DevInterestRatesView.vuestale type references — view was using removed/renamedReturnResultproperties (isValid,dataPoints,latestDate,referenceDate); updated to current fields (value !== null,actualDays,endDate,startDate). Also typedperiodLabelsasPartial<Record<Period, string>>since the 3m period is intentionally excluded from this view.firestoreService.tsnon-null narrowing —snapshotSnap.docs[0]flagged as possiblyundefineddespite!emptyguard; 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-18Premium Branding & Logo Refinement
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.pngand 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.htmlmeta 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.vuecomponent in favor of the new branded raster assets.
2026-04-18Dual-Track Returns, Email Corrections & Infrastructure
Dual-Track Returns, Email Corrections & Infrastructure
Fixed
- EAR mislabeling bug —
ret_1w(and allret_*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/1ynow store simple returns (actual period gain). Six newret_*_earfields 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 toret_*_earfields since it displays all periods side-by-side. Column headers now explicitly labeledp.a.All other components (LatestFundData, DailyPulse, DeepInsightCards, PulseHero, WatchlistWidget) continue using simpleret_*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_3mandret_*_eartype fields added toEnrichedFundDatainterfaces infirestoreService.tsandfundDataService.tsEmail send logging (
appscripts/lib.emailLog.gs) —logEmailBatch_()appends one row per send batch to the History spreadsheet:sent_emailsfor production,sent_as_testfor test runs. Captures timestamp, type, report_id, subject, sent_count, failed_count, is_test. Wired intosendWeeklyPulseReport,testWeeklyReport, andsendCorrection_.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-18Firebase Backend: Daily QA Report & Frontend Lint
Firebase Backend: Daily QA Report & Frontend Lint
Added
Daily QA Email Report (
functions/src/reports.dailyEmail.ts) — Portsreports.dataQuality.gsfrom App Script to Firebase Cloud Functions. A newdailyQAReportscheduled 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 —
syncFundDatanow writes apipeline_logsFirestore 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 unusedtand deadformat()helperSidebar.vue— addeddefineOptions({ name: 'AppSidebar' })for multi-word component ruleComparisonMatrixView.vue— dropped unusedFilterimport; replacedanywithRecord<string, unknown>in sort,formatValue, andgetRiskLevelGoalsView.vue— removed unusedcomputed,PinOffimport, andformatDateReportSnapshotView.vue— removed unusedgetFundIconfunctionWeeklyCheckupView.vue— removed unusedChevronRightandt; replacedcatch (e: any)withunknown+instanceof Errorguard
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.
nodemailerlazy-loaded insidesendDailyQAReport(not at module init) to keep function cold-start time under the CLI analysis threshold.
Historia ya Matoleo
Historia ya Matoleo
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
componentRefpattern for element-based exports
Bug Fixes:
- Fixed watermark URL from
navviewtz.vercel.apptonav-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
Imejengwa kwa ❤️ kwa ajili ya jumuiya ya uwekezaji ya Tanzania
Toleo: f40d755