Product page: lifetime price, rounded usage, roadmap (#126)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 3m23s

## Summary
- Closes #123.
- Hide account and document counts until there are 20 accounts. After that, both figures round down to one significant digit and show a plus, so the page never publishes an exact count.
- The product page and pricing page say early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.
- The product page adds a roadmap section for image generation and vision.

## Test plan
- [ ] With fewer than 20 accounts, the product page shows no usage figures.
- [ ] With 23 accounts and 156 documents, the page shows 20+ accounts and 100+ documents stored, plus the as-of date.
- [ ] The product page states the lifetime $10 lock and lists image generation and vision under Roadmap.
- [ ] The pricing page states the same lifetime lock.

Reviewed-on: #126
This commit was merged in pull request #126.
This commit is contained in:
westfarn committed 2026-10-19 08:14:49 -07:00
1 parent 7a8f413483
commit 36341f6e72
12 files changed
+160 -14

No files matched your search

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { formatPublicCount, shouldShowPublicUsage } from './publicCount';
import { publicText } from './publicColors';
const Section = styled.section`
@@ -26,12 +27,9 @@ type PublicUsage = {
as_of: string;
};
function countLabel(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}
/**
* Live counts only. Hidden when the API fails so the page never shows a made-up number.
* Live counts only. Hidden when the API fails, or until 20 accounts exist, so the page
* never shows a made-up number. Once shown, both figures are rounded down and marked with +.
* Avatars are omitted: no one has consented to publish a photo.
*/
const UsageCounts = (): JSX.Element | null => {
@@ -65,16 +63,16 @@ const UsageCounts = (): JSX.Element | null => {
};
}, []);
if (!usage) return null;
if (!usage || !shouldShowPublicUsage(usage.account_count)) return null;
const asOfDate = usage.as_of.slice(0, 10);
return (
<Section aria-label="Usage">
<Figures>
{countLabel(usage.account_count, 'account', 'accounts')}
{formatPublicCount(usage.account_count, 'accounts')}
{' · '}
{countLabel(usage.document_count, 'document stored', 'documents stored')}
{formatPublicCount(usage.document_count, 'documents stored')}
</Figures>
<AsOf>
As of <time dateTime={usage.as_of}>{asOfDate}</time>
@@ -0,0 +1,28 @@
import { formatPublicCount, roundedPublicCount, shouldShowPublicUsage } from './publicCount';
describe('public usage counts', () => {
it('stays hidden until 20 accounts', () => {
expect(shouldShowPublicUsage(0)).toBe(false);
expect(shouldShowPublicUsage(19)).toBe(false);
expect(shouldShowPublicUsage(19.5)).toBe(false);
expect(shouldShowPublicUsage(20)).toBe(true);
expect(shouldShowPublicUsage(156)).toBe(true);
});
it('rounds down to one significant digit and marks the bucket with +', () => {
expect(roundedPublicCount(9)).toBeNull();
expect(roundedPublicCount(10)).toBe(10);
expect(roundedPublicCount(23)).toBe(20);
expect(roundedPublicCount(99)).toBe(90);
expect(roundedPublicCount(100)).toBe(100);
expect(roundedPublicCount(156)).toBe(100);
expect(roundedPublicCount(999)).toBe(900);
expect(roundedPublicCount(1000)).toBe(1000);
expect(roundedPublicCount(2500)).toBe(2000);
expect(formatPublicCount(4, 'documents stored')).toBe('under 10 documents stored');
expect(formatPublicCount(23, 'accounts')).toBe('20+ accounts');
expect(formatPublicCount(156, 'documents stored')).toBe('100+ documents stored');
expect(formatPublicCount(2500, 'accounts')).toBe('2,000+ accounts');
});
});
@@ -0,0 +1,34 @@
/** Hide live counts until this many accounts exist. Below that, the figures are noise. */
export const MIN_PUBLIC_ACCOUNTS = 20;
export function shouldShowPublicUsage(accountCount: number): boolean {
return Number.isInteger(accountCount) && accountCount >= MIN_PUBLIC_ACCOUNTS;
}
/** Largest power of ten that does not exceed count. 23 → 10, 156 → 100, 2500 → 1000. */
function stepFor(count: number): number {
let step = 1;
while (step * 10 <= count) step *= 10;
return step;
}
/**
* Floor to one significant digit so the public figure never exceeds the real count.
* Counts under 10 have no honest "+" bucket.
*/
export function roundedPublicCount(count: number): number | null {
if (!Number.isInteger(count) || count < 10) return null;
const step = stepFor(count);
return Math.floor(count / step) * step;
}
function grouped(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** "20+ accounts" or "under 10 documents stored". Noun is the already-plural label. */
export function formatPublicCount(count: number, noun: string): string {
const rounded = roundedPublicCount(count);
if (rounded === null) return `under 10 ${noun}`;
return `${grouped(rounded)}+ ${noun}`;
}
@@ -50,6 +50,12 @@ describe('LandingPage', () => {
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Roadmap' })).toBeInTheDocument();
expect(screen.getByText(/Image generation is on the roadmap/i)).toBeInTheDocument();
expect(screen.getByText(/Vision is on the roadmap/i)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
@@ -59,9 +65,34 @@ describe('LandingPage', () => {
expect(screen.getAllByRole('link', { name: 'Sign in' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: 'Pricing' })[0]).toHaveAttribute('href', '/pricing/');
expect(await screen.findByText(/3 accounts/)).toBeInTheDocument();
expect(screen.getByText(/12 documents stored/)).toBeInTheDocument();
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/public/usage/'));
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
expect(screen.queryByText(/3 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/12 documents/)).not.toBeInTheDocument();
});
it('shows rounded account and document counts once there are 20 accounts', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') {
return Promise.resolve({
data: { account_count: 23, document_count: 156, as_of: '2026-10-17T15:00:00+00:00' },
});
}
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(await screen.findByText(/20\+ accounts/)).toBeInTheDocument();
expect(screen.getByText(/100\+ documents stored/)).toBeInTheDocument();
expect(screen.getByText('2026-10-17')).toBeInTheDocument();
expect(screen.queryByText(/23 accounts/)).not.toBeInTheDocument();
expect(screen.queryByText(/156 documents/)).not.toBeInTheDocument();
});
it('links to create account when registration is on', async () => {
@@ -82,6 +82,29 @@ const FeatureBody = styled.p`
line-height: 1.5;
`;
const PriceLock = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: ${publicText};
font-weight: 600;
`;
const Roadmap = styled.section`
margin-top: 1rem;
border: 1px solid ${publicLine};
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: ${publicCard};
display: grid;
gap: 0.75rem;
`;
const RoadmapTitle = styled.h2`
font-size: 1.15rem;
margin: 0;
`;
const LandingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
@@ -90,6 +113,7 @@ const LandingPage = (): JSX.Element => {
<Title>Hesychia is a calm AI assistant.</Title>
<Lede>{HOME.lede}</Lede>
<Memory>{HOME.memory}</Memory>
<PriceLock>{HOME.earlyAdopter}</PriceLock>
<GetStarted source="landing" />
<Privacy aria-label="Privacy">
<h2>What we do with your data</h2>
@@ -115,6 +139,11 @@ const LandingPage = (): JSX.Element => {
<FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>{HOME.businessFeature}</FeatureBody>
</Feature>
<Roadmap aria-labelledby="roadmap-title">
<RoadmapTitle id="roadmap-title">Roadmap</RoadmapTitle>
<FeatureBody>{HOME.roadmapImage}</FeatureBody>
<FeatureBody>{HOME.roadmapVision}</FeatureBody>
</Roadmap>
</Features>
</PublicSiteLayout>
);
@@ -22,6 +22,10 @@ describe('PricingPage', () => {
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat, with no ads, no upsells, and one price/)).toBeInTheDocument();
expect(
screen.getByText(/Early adopters pay \$10 per month, locked for the lifetime of the account/i),
).toBeInTheDocument();
expect(screen.getByText(/that price never goes up/i)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
});
@@ -31,6 +31,7 @@ const PricingPage = (): JSX.Element => {
<PublicSiteLayout>
<Title>Pricing</Title>
<Price>{PRICING.price}</Price>
<Body>{PRICING.lock}</Body>
<Body>
{PRICING.plan} {PRICING.compare}
</Body>
@@ -26,6 +26,8 @@ describe('public crawler files', () => {
}
expect(sitemap.startsWith('<?xml')).toBe(true);
expect(llms).toContain('$10/month');
expect(llms).toContain('locked for the lifetime of the account');
expect(llms).toContain('Image generation and vision are on the roadmap');
expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
@@ -48,6 +50,13 @@ describe('public crawler files', () => {
expect(html).toContain('href="/pricing/">Hesychia pricing — $10/month, no ads');
expect(html).toContain('href="/privacy/">What Hesychia stores and does not sell');
expect(html).toContain('href="/account-deletion/">How to request Hesychia account deletion');
expect(html).toContain(
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.',
);
expect(html).toContain(
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
);
expect(html).toContain('Vision is on the roadmap, so you can ask questions about an image you upload.');
expect(html).not.toContain('>here<');
expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
expect(html).toContain('"@type": "WebPage"');
+4
View File
@@ -7,10 +7,14 @@ export const HOME: {
memoryFeature: string;
documentsFeature: string;
businessFeature: string;
earlyAdopter: string;
roadmapImage: string;
roadmapVision: string;
};
export const PRICING: {
price: string;
lock: string;
plan: string;
compare: string;
};
+9 -1
View File
@@ -1,6 +1,5 @@
/**
* Visible sentences for each public route. Each one is 10–25 words.
* Wording is the existing page, split or joined. No new claims.
*/
const { PAGE_PATHS } = require('./canonicals');
@@ -13,6 +12,9 @@ function wordCount(sentence) {
.filter(Boolean).length;
}
const EARLY_ADOPTER =
'Early adopters pay $10 per month, locked for the lifetime of the account, and that price never goes up.';
const HOME = {
lede: 'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
memory:
@@ -26,10 +28,16 @@ const HOME = {
'Your documents stay stored and searchable, and answers are grounded in your own files.',
businessFeature:
'Business accounts are simple management for your team, and company managers can manage team accounts.',
earlyAdopter: EARLY_ADOPTER,
roadmapImage:
'Image generation is on the roadmap, so a prompt can create a picture inside Hesychia.',
roadmapVision:
'Vision is on the roadmap, so you can ask questions about an image you upload.',
};
const PRICING = {
price: '$10/month, flat, with no ads, no upsells, and one price.',
lock: EARLY_ADOPTER,
plan: 'One public plan covers quiet, focused AI chat, your documents, and business accounts for your team.',
compare: 'Hard to justify $20 a month, and this is $10.',
};