Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions content/updates/1.18.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
version: 1.18.0
date: 2026-10-24
title: Find me a tutor
summary: Answer a few questions and get the three tutors who fit best, all with open times in the next 14 days, on one screen.
image: /updates/find-a-tutor.webp
imageAlt: The Find me a tutor page with questions about language, level, price and time of day on the left, and three suggested Spanish tutors with their reasons, next free time and price on the right
---
- A new page, Find me a tutor, asks for the language you want, your level, your price range, when you can have lessons and whether you want a trial lesson.
- It shows the three tutors who fit best, each with the reasons it picked them, their next free time in your time zone, their price and a Book button.
- Every tutor shown has open times in the next 14 days, so each one can be booked today. You do not need an account.
- If fewer than three tutors fit, the page says so and shows what to change to see more. If none fit, you can start again or see everyone.
- The page address remembers your answers, so you can share them. It works in a dark theme that follows your device setting, and on a phone the button stays in reach.
2 changes: 1 addition & 1 deletion docs/feature-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ This file is long and every feature used to add a bullet at the same place, so t

- A home-made measure must not claim more than it checks (T-0641): `tests/support/measure-claims.ts` is the table of every kind a measure says it catches (`scripts/mobile-audit.mjs`, the source scans in `tests/support/mobile-scans.ts`, the rate-limit check): one sample in the broken state and one in the right state per kind. `tests/measure-claims.test.ts` (in `npm test`) runs the REAL measure on both (the browser measures run on the HTML samples in a small fake page, `tests/support/mini-dom.ts`) and fails when a comment in those files claims a catch ("catches", "must stay", "no longer", "is reported" ...) without a `[claim: id]` marker of a row, or a row is not claimed. One marker cannot answer for a list: when a marked comment names a kind (summary, tab, menu item, checkbox, select, textarea, min-w-[Npx], style width, grid-cols, text-xs ...), a row it marks must list that kind in `covers` (`KIND_WORDS` in the same file holds the words per measure file; kept by hand), and a row can have more samples (`moreBroken`, `moreGood`). To add a measure: write the claim with `[claim: id]` in its comment, add one row per kind it names, and add the kind words. What the comments say but the table does not hold (the other measures of `audit()`, the scanners that have their own self-check in `tests/rate-limits.test.ts`) is listed with the reason in `LEFT_WITHOUT_ROW` of the same file; those comments only describe. Today the table has [count: CLAIMS=30] rows, the comments of [count: MEASURE_FILES=3] files are read, and [count: LEFT_WITHOUT_ROW=4] things are left without a row.

- Accessibility of the public pages (T-0638): `tests/accessibility.test.ts` ([count: ACCESSIBILITY_TESTS=12] tests) keeps pinch-zoom allowed, exactly one `<main>` on each of the [count: PUBLIC_PAGES=20] public pages (a layout above a page counts), and no pale grey text (`text-gray-200`, `-300`, `-400`; icons and disabled controls are listed with a reason in `GREY_EXEMPT`) in the [count: PUBLIC_TEXT_FILES=26] files of `PUBLIC_TEXT_FILES` and in every layout above a public page.
- Accessibility of the public pages (T-0638): `tests/accessibility.test.ts` ([count: ACCESSIBILITY_TESTS=12] tests) keeps pinch-zoom allowed, exactly one `<main>` on each of the [count: PUBLIC_PAGES=21] public pages (a layout above a page counts), and no pale grey text (`text-gray-200`, `-300`, `-400`; icons and disabled controls are listed with a reason in `GREY_EXEMPT`) in the [count: PUBLIC_TEXT_FILES=26] files of `PUBLIC_TEXT_FILES` and in every layout above a public page.
- Legibility of the tutor app (T-0842): `tests/dashboard-legibility.test.ts` keeps the floor in `src/styles/dashboard-shell.css` (the last block): pale greys (`text-gray-200` to `-500`) read the third ink, sizes `text-[9px]` to `text-[11px]` are 12px, amber, emerald, indigo and red text read the shell tokens, and the bell badge has dark text in dark. It scans every page of the tutor app and the components they draw for a size under 12px the floor does not cover, and for an icon-only back link with no `aria-label`. Pages not yet rebuilt rely on the floor; rebuilt pages use the tokens directly. Second pass (T-0842, same file): a placeholder reads the third ink (`.dash ::placeholder`, it was `placeholder:text-gray-300`, 1.47 to 1) and the shadcn muted text does too (`--muted-foreground` set in the `.dash` block, so a select's placeholder is readable in the dark theme); every `input`, `textarea` and `select` of the tutor app (the pages, the components they draw and `src/components/settings`) must have a label for it, an `aria-label` or sit inside a `<label>`, because a placeholder is not a name (`unnamedControls`, with a mutation on the real lesson form); the week grid of the Calendar opens one hour before the first lesson of the week, or one hour before now, instead of at 6 AM (`openingMinute` in `src/lib/calendar.ts`, `tests/calendar-opening.test.ts`).
- Brand, titles and answers (T-0850): `tests/seo-brand.test.ts` keeps one title pattern ("Topic | Tutafy"; the home page and the two posts about Tutafy start with it, within 60 characters, `BLOG_SEO` adds the brand to a post title that lacks it), the share picture `src/app/og/route.tsx` (1200 x 630 PNG with the name; every page points to `/og`), the first three FAQ answers (what Tutafy is, who it is for, how it differs from a marketplace: home page, About, `/llms.txt` "In short"), the directory FAQ with its FAQPage data from one list (`TUTORS_FAQ`), the AI tool count the public pages state (the number in the app), the lesson price in a profile only when real (`lessonOffer`, never a rating of its own), and an alternative text on every public image.
- Public pages review (T-0843): `tests/seo-public-pages.test.ts` keeps the structured data of the index pages (`pageJsonLd` in `src/lib/seo.ts`: about, contact, customers, updates, blog and the tutor directory say what kind of page they are, with a breadcrumb and the items they list, never an e-mail address), the own title and noindex of the 404 page (`src/app/not-found.tsx`), and indigo-600 (not 500) for small labels and the buttons and bands that carry white text. The voice of the site is written down in `docs/brand-voice.md` (a draft for the owner to approve).
Expand Down
29 changes: 29 additions & 0 deletions docs/features/T-0897-find-a-tutor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# T-0897: Find me a tutor (`/find-a-tutor`), design option A of T-0864

One screen: five questions on the left, the three tutors that fit best on the right (under the form on a phone, where the button stays in reach). Built from the approved sample `docs/find-tutor-design` (pull request #90, option A).

## What it does

- `src/app/find-a-tutor/page.tsx` (server) reads the directory list (`getDirectory`, the same cached public cards as `/tutors`), reads the answers from the address with the directory's own `parseFilters` and hands both to `src/components/find/FindTutorView.tsx` (client). It also draws the four-question FAQ and its `FAQPage` data from one list (`FIND_TUTOR_FAQ` in `src/lib/site-content.ts`).
- The answers are the directory's filters: language, level, price range, time of day (in the visitor's time zone), trial lesson, and the time zone itself. `formFilters` in `src/lib/find-tutor.ts` keeps only these, so a search word, a day, "near me" or a sort in the address changes nothing silently.
- `findTutors` takes the top three of `applyFilters` in the directory's "Recommended" order. `whyThisTutor` writes up to three reasons and prints one only when it is true of that card (a language or level asked for, a free time that really falls in the chosen time of day, the price range, the trial lesson, then rating and next free time to fill the list).
- With scripts the three tutors follow every answer (the address is kept for sharing); without scripts the form is a plain GET form and the server draws the answer for the address.
- States: three tutors; fewer than three (it says what to drop, with the number of tutors that gives); none (an explained card with "Start again" and "See all tutors"); under six listed tutors (the finder is closed, like the directory, with a link to the list); a failed read (an alert with a retry link and `noindex`, never an empty finder).
- Every answer is a real radio button (a switch for the trial lesson) inside a `fieldset` with a legend, so the keyboard and screen readers work as for any form. The time zone sits in a `details` element.
- Dark: the page uses the directory's `.dir-page` dark scheme (`src/styles/directory-dark.css`); one rule keeps a chosen answer filled in dark (`--dir-chosen`, checked by `tests/find-tutor.test.ts`).

## Added around it (so nothing points at a page that is not there)

- `PAGE_SEO.findTutor` (title, description, canonical), the sitemap, `KEY_PAGES` for `/llms.txt`, the footer link, a link from the directory ("Not sure who to pick?"), and the visitor-journey list (`src/lib/track-path.ts`, path only).
- The registries that list public pages: `tests/accessibility.test.ts`, `tests/seo-metadata.test.ts`, `tests/updates.test.ts`, and the count marker in `docs/feature-map.md`.
- Updates entry 1.18.0 with its picture (`public/updates/find-a-tutor.webp`).

## Tests

`tests/find-tutor.test.ts`: the pick (at most three, only tutors that fit, best first, what to drop), the reasons (only true ones), `formFilters`, the real page over made-up tutors (the three picks, a narrowed list, no match, closed, failed read, nothing private in the HTML, the form markup, the address read like the directory's, title, description, canonical, structured data and FAQ) and the dark contrast.

## Not done

- The sample's "What for" question (travel, work, exam...) is not on the form: a tutor does not say what they teach it for, so no tutor could be matched to it honestly. It needs a field tutors fill in (a separate ticket).
- The sample's "More..." opens a list; here it is a select, which also works without scripts.
- A tutor who has not chosen levels in Settings is not matched when a level is asked for (the directory's rule), so until more tutors choose, "Any" level gives the most results.
Binary file added public/updates/find-a-tutor.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/app/find-a-tutor/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { pageMetadata } from '@/lib/page-meta'
import '@/styles/directory-dark.css'

// The finder is a server page with a client form; the title, description and canonical address come from PAGE_SEO.findTutor. The dark colour
// scheme is the directory's own (it follows the visitor's system setting, inside `.dir-page` only).
export const metadata = pageMetadata('findTutor')

export default function FindTutorLayout({ children }: { children: React.ReactNode }) {
return children
}
64 changes: 64 additions & 0 deletions src/app/find-a-tutor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Link from 'next/link'
import { SiteFooter } from '@/components/SiteFooter'
import { SiteLogo } from '@/components/SiteLogo'
import { FindTutorView } from '@/components/find/FindTutorView'
import { getDirectory } from '@/lib/directory-cache'
import { parseFilters, refreshCards } from '@/lib/directory'
import { formFilters } from '@/lib/find-tutor'
import { PAGE_SEO } from '@/lib/page-meta'
import { FIND_TUTOR_FAQ } from '@/lib/site-content'
import { faqPageJsonLd, jsonLdScript, pageJsonLd } from '@/lib/seo'

// "Find me a tutor" (T-0897): a student answers a few questions and gets the three tutors that fit best. The tutors are the public cards of the
// directory (src/lib/directory-cache.ts: only tutors who can be booked today, a fixed list of keys), so the finder cannot show a tutor the
// directory would not. The answers come from the address (?lang=...&level=...), checked against fixed lists like the directory's.
type Params = Record<string, string | string[] | undefined>

/** A form sends an empty value for "Any" next to a chosen one (a button and the "More…" list share the name `lang`): the first value that is not empty wins. */
function firstFilled(params: Params): Params {
const out: Params = {}
for (const [k, v] of Object.entries(params)) out[k] = Array.isArray(v) ? (v.find(x => x !== '') ?? '') : v
return out
}

export default async function FindTutorPage({ searchParams }: { searchParams: Promise<Params> }) {
const params = firstFilled(await searchParams)
const directory = await getDirectory()
const now = new Date()
const cards = directory ? refreshCards(directory.cards, now) : []

return (
<div className="dir-page min-h-screen bg-[#F7F8FA]">
{directory !== null && pageJsonLd(PAGE_SEO.findTutor, 'Find me a tutor', 'CollectionPage').map((d, i) => <script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(d) }} />)}
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(faqPageJsonLd(FIND_TUTOR_FAQ)) }} />
<header className="bg-white border-b border-gray-100 px-6 py-3.5">
<div className="max-w-[1120px] mx-auto flex items-center justify-between">
<SiteLogo size="md" />
<div className="flex items-center gap-4">
<Link href="/tutors" className="text-[13px] font-medium text-gray-600 hover:text-gray-900 transition-colors">All tutors</Link>
<Link href="/student/login" className="text-[13px] font-medium text-gray-600 hover:text-gray-900 transition-colors">Student login →</Link>
</div>
</div>
</header>

{/* a page that could not read the list is an error, not content: it must not be indexed (the status code of a page cannot be set here) */}
{directory === null && <meta name="robots" content="noindex" />}
<main>
<FindTutorView cards={cards} initial={formFilters(parseFilters(params))} nowIso={now.toISOString()} error={directory === null} listed={directory?.listed ?? 0} />
<section aria-labelledby="find-faq-h" className="max-w-[1120px] mx-auto px-4 pb-12">
<h2 id="find-faq-h" className="text-[22px] font-extrabold text-gray-900">Questions about finding a tutor</h2>
<dl className="mt-5 grid gap-x-10 gap-y-6 md:grid-cols-2">
{FIND_TUTOR_FAQ.map(f => (
<div key={f.q}>
<dt className="text-[15px] font-bold text-gray-900">{f.q}</dt>
<dd className="text-[14px] text-gray-700 mt-1.5 leading-relaxed">{f.a}</dd>
</div>
))}
</dl>
</section>
</main>

<SiteFooter />
</div>
)
}
1 change: 1 addition & 0 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages: { path: string; priority: number; freq: MetadataRoute.Sitemap[number]['changeFrequency'] }[] = [
{ path: '', priority: 1.0, freq: 'weekly' },
{ path: '/tutors', priority: 0.8, freq: 'daily' },
{ path: '/find-a-tutor', priority: 0.7, freq: 'daily' },
{ path: '/blog', priority: 0.7, freq: 'weekly' },
{ path: '/updates', priority: 0.5, freq: 'weekly' },
{ path: '/about', priority: 0.6, freq: 'monthly' },
Expand Down
1 change: 1 addition & 0 deletions src/components/SiteFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SiteLogo } from '@/components/SiteLogo'
// public page file for one or the other).
export const FOOTER_LINKS = [
{ href: '/tutors', label: 'Find tutors' },
{ href: '/find-a-tutor', label: 'Find me a tutor' },
{ href: '/about', label: 'About' },
{ href: '/blog', label: 'Blog' },
{ href: '/updates', label: 'Updates' },
Expand Down
1 change: 1 addition & 0 deletions src/components/directory/DirectoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function DirectoryView({ cards, listed, truncated, initial, nowIso, error
<div>
<h1 className="text-[28px] min-[721px]:text-[34px] leading-[1.1] tracking-tight font-extrabold text-gray-900">Find a language tutor you can book today</h1>
<p className="text-[15px] min-[721px]:text-[16px] text-gray-600 mt-2 max-w-[640px]">Every tutor here has open times in the next 14 days. Book with just your name and email.</p>
<p className="text-[14px] text-gray-700 mt-2" data-testid="find-link">Not sure who to pick? <Link href="/find-a-tutor" className="font-bold text-indigo-700 underline underline-offset-2">Find me a tutor</Link></p>
<p className="inline-flex items-center gap-1.5 text-[13px] text-gray-600 mt-2.5" data-testid="tz-line"><Globe className="h-3.5 w-3.5" aria-hidden="true" /> {tzLine}
<button type="button" onClick={() => setPanel(window.matchMedia('(min-width: 721px)').matches ? 'zone' : 'sheet')} className="min-h-11 -my-3 px-1.5 font-semibold text-indigo-700 underline underline-offset-2" data-testid="tz-change">Change</button></p>
</div>
Expand Down
Loading
Loading