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
15 changes: 15 additions & 0 deletions content/updates/1.14.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
version: 1.14.1
date: 2026-10-23
title: Easier to read in light and dark
summary: Pale grey text, tiny labels and low-contrast buttons across the tutor app are now clear, and the icon-only buttons have names for screen readers.
image: /updates/legible-text.webp
imageAlt: The Upgrade page with the Pro plan card in a deeper blue, where every line of text is now easy to read
---
- Pale grey text on the Dashboard, student and lesson pages, the forms, AI Tools and Upgrade is darker, so it passes the 4.5 to 1 contrast rule.
- Nothing is smaller than 12px any more: the small day labels, status chips and notes were 9 to 11px.
- On the Upgrade page the Pro plan card is a deeper blue, so its small text and the "Current plan" badge are readable.
- Status chips (scheduled, overdue, active) and green or blue buttons keep their colours but are darker, so the white text on them is clearer.
- The notification count in the top bar is readable in the dark theme.
- The back arrows, the send button in Messages and the add buttons in the lesson form have names for screen readers.
- The weekly chart on the Dashboard no longer puts its "1 lesson" label over the heading.
13 changes: 13 additions & 0 deletions content/updates/1.16.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
version: 1.16.2
date: 2026-10-23
title: The Calendar opens where your lessons are
summary: The week grid scrolls to your first lesson of the week instead of 6 AM, and every field in the lesson, student, team and Settings forms now has a name for screen readers.
image: /updates/calendar-opens-at-lessons.webp
imageAlt: The week view of the Calendar opened at midday, with the day names kept above the hours and two lessons in view
---
- The week view opens one hour before your first lesson of the week, or one hour before now when the week has no lessons. It used to start at 6 AM, so the first screen was mostly empty hours.
- The hours scroll inside the grid and the day names stay above them.
- Hints inside empty fields, like the Zoom link in a lesson, are darker and easy to read.
- Every field in the lesson, student, Team, Messages and Settings forms has a name that a screen reader says, not only a hint inside the box.
- The Google Calendar button in Settings and the Plan card have clearer text.
3 changes: 2 additions & 1 deletion docs/feature-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ Tutafy: booking, lessons and payments for private tutors (Next.js 16, Supabase,
- 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=19] 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).

- Numbers that count a list in the code (T-0646): never type such a number bare in a report or in this map. Write it as a marker, the word count, a colon, the list name, an equals sign and the number inside square brackets, as in [count: NAME=N]. `tests/support/counted-lists.ts` maps each list name to a function that counts the real list (an exported array, the literal of a constant, the `test(` calls of a file); `tests/counted-lists.test.ts` (in `npm test`) turns red when a marker in `docs/` or `README.md` has a different number than the code, names a list the table does not have, or is written wrongly. Run `npm run counts` for the numbers now, to copy. The project's status file lives in the Helme Brain, outside this repository, so `npm test` does not read it (set `COUNTS_STATUS_FILE` to a path to have the test read it too); before a report is filed, check it with `npm run counts -- <path of the status file>` (exit code 1 on a wrong marker). To count a new list, add a row to `COUNTED_LISTS`. A list that a checkout does not have cannot be counted there, and a marker for it is reported. Today the table has [count: COUNT_LIST_TESTS=14] tests of its own and the repository has [count: TEST_FILES=97] test files.
- Numbers that count a list in the code (T-0646): never type such a number bare in a report or in this map. Write it as a marker, the word count, a colon, the list name, an equals sign and the number inside square brackets, as in [count: NAME=N]. `tests/support/counted-lists.ts` maps each list name to a function that counts the real list (an exported array, the literal of a constant, the `test(` calls of a file); `tests/counted-lists.test.ts` (in `npm test`) turns red when a marker in `docs/` or `README.md` has a different number than the code, names a list the table does not have, or is written wrongly. Run `npm run counts` for the numbers now, to copy. The project's status file lives in the Helme Brain, outside this repository, so `npm test` does not read it (set `COUNTS_STATUS_FILE` to a path to have the test read it too); before a report is filed, check it with `npm run counts -- <path of the status file>` (exit code 1 on a wrong marker). To count a new list, add a row to `COUNTED_LISTS`. A list that a checkout does not have cannot be counted there, and a marker for it is reported. Today the table has [count: COUNT_LIST_TESTS=14] tests of its own and the repository has [count: TEST_FILES=99] test files.

- A line citation must point inside its file (T-0684): a report that writes "`src/lib/x.ts` dòng 40-52" or "src/lib/x.ts:40-52" is checked by `tests/line-refs.test.ts` (helper `tests/support/line-refs.ts`, in `npm test`): the file must exist and N and M must be lines of it (1 <= N <= M <= its length); a bare file name or the end of a path ("settings/page.tsx") is wrong only when it is too short for every file it names. A list is read to its last number ("`file` dòng 64 và 99", "dòng 75, 88", "dòng 163 và 280-281", "path:10-12, 40"), and so is the number-first form ("dòng 75-93 của `file`", "lines 3-9 of file"); EVERY number of a list is taken as a line whatever word follows it ("dòng 10 và 83 kiểm" checks 83), with ONE exception on purpose: a number followed by a unit of counting or measuring (test, ký tự, chars, ms, giây, phút, giờ, ngày, tệp, files, lần, dòng, lines, bytes, trang, mục, người, buổi, % ... : the closed list `UNIT` in `tests/support/line-refs.ts`) counts something else, so "dòng 196, 45 ký tự" and "dòng 12 và 300 test khác" read only 196 and 12; a unit that is not on the list is read as a line. A bare number after a comma is always a line, so write "dòng 18-20, trả mã 401", not "dòng 18-20, 401". It does NOT read a line number with no file name in the same phrase ("dòng 80" alone, "(dòng 23-24)" when the file was named in an earlier sentence), "path#L40", or a path written with a backslash: those are not checked. It reads the Markdown files in the root and under `docs/`, always. The project's status file lives in the Helme Brain, outside this repository, so check it with `npm run line-refs -- <path of the status file>` (exit code 1 when a citation is past the end; a file that is on no branch of this clone is listed, not failed; a file longer on another branch than here is not a mistake, because the status file speaks about several branches at once), or set `LINE_REFS_STATUS_FILE` to have `npm test` read it too. Only the length is checked: a citation still inside the file but no longer on the lines it quotes is for a human to recheck, so open the file and count the lines before writing them. The pull request workflow `.github/workflows/pull-request.yml` runs `npm run typecheck`, `npm test` and `npm run line-refs` on every pull request.
- A merged pull request must not be written as waiting (T-0693): `tests/pr-status.test.ts` (helper `tests/support/pr-status.ts`, in `npm test`) finds every pull request number (`#9999`, `pull request 9999`, `PR #9999`, `/pull/9999`) that belongs to a waiting phrase: "chờ ... gộp" with up to five words between ("chờ gộp", "chờ chủ gộp", "chờ review rồi chủ gộp", and the list forms Helme writes: "Việc chờ W-2067 (gộp #9999)", "Việc chờ: W-2067 (gộp #9999)", "Việc chờ của chủ: W-1918 (gộp #9998)": a colon after "chờ", a colon, comma or bracket inside, "gộp" after a bracket), "chủ gộp" ("W-1844: chủ gộp pull request #1"; "chủ đã gộp" is not one), "chưa gộp", "chờ merge", "awaiting merge", "waiting to be merged" or "not yet merged". A number belongs to the phrase when it is on the same line within 160 characters, or, when the phrase's line has none, when it is the nearest number of the same list item (a "- T-xxxx" or "- W-xxxx" with its indented lines, at most 5 lines away; a heading, a new list item or a new paragraph ends the item). It asks GitHub, read-only (`gh pr view <n> --json state`), what it is: MERGED is a failure. A number with "đã gộp" or "is merged" written next to it is the text saying it is merged and is not asked, but only its OWN word counts: the word belongs to the nearest number on its line, so "PR #38 đã gộp, PR #39 chờ gộp" still asks about #39, and "đã gộp" anywhere in the item for the same number clears it. NOT caught: a waiting sentence in other words ("còn mở", "chưa vào master", "đợi duyệt"), "chờ" and "gộp" with a full stop, a semicolon or more than five words between them ("Việc chờ cho chủ: W-1909 (đọc kết quả rồi bấm Done; gộp #9997 ...)"), a phrase with no pull request number in its item or more than 5 lines away, a number written without "#", "PR" or "pull request" in front of it; when a merged pull request is mentioned only in the past ("trước đó chờ gộp"), write "đã gộp" next to its number. When GitHub cannot be reached (no gh, not logged in, no network) the numbers are listed as skipped and nothing fails. It reads the same Markdown files as the line-citation check, and asks GitHub only when one of them has such a phrase. The status file is checked with `npm run pr-status -- <path of the status file>` (exit code 1 on a merged pull request still waiting), or set `MERGED_PR_STATUS_FILE` to have `npm test` read it; when a pull request is merged, change its entry to say so, with the merge commit, instead of leaving the waiting words. The tests use a pretend gh (`PR_STATUS_GH_SCRIPT`), never the network. The pull request workflow runs it as the last step, with a token that can only read pull requests.
Expand Down
Binary file added public/updates/calendar-opens-at-lessons.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/updates/legible-text.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/app/(dashboard)/ai/AiToolsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export function AiToolsClient() {
<div className="p-5 space-y-4">
{tool.multiline ? (
<textarea
aria-label={tool.title}
value={input}
onChange={e => setInput(e.target.value)}
placeholder={tool.placeholder}
Expand All @@ -163,6 +164,7 @@ export function AiToolsClient() {
/>
) : (
<input
aria-label={tool.title}
value={input}
onChange={e => setInput(e.target.value)}
placeholder={tool.placeholder}
Expand Down
17 changes: 14 additions & 3 deletions src/app/(dashboard)/calendar/CalendarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
// 768px a day-by-day list. Moving a lesson asks POST /api/lessons/reschedule; if the new time overlaps another lesson the
// server answers 409 and this view shows which, and moves it only when the tutor says "Move anyway". Dragging needs a mouse,
// so every lesson also has a "Move to" form (keyboard, phone). Dates are read in the browser's own time zone, as before.
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { formatMoney } from '@/lib/currency'
import {
GRID_FROM_HOUR, GRID_TO_HOUR, HOUR_PX, VIEWS, addDays, blockOf, dayKey, dropTarget, minutesAtOffset, onDay, parseDay, rangeFor, shift, weekDays, type CalView,
GRID_FROM_HOUR, GRID_TO_HOUR, HOUR_PX, VIEWS, addDays, blockOf, dayKey, dropTarget, minutesAtOffset, onDay, openingMinute, parseDay, rangeFor, shift, weekDays, type CalView,
} from '@/lib/calendar'

const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
Expand All @@ -37,6 +37,17 @@ export default function CalendarView({ view, anchor, lessons, currency = 'USD' }
const [dragId, setDragId] = useState<string | null>(null)
const selected = lessons.find(l => l.id === selectedId) ?? null

// Open the week grid at the first lesson (or now) instead of 6 AM; only when the grid is on screen (a phone shows a day list).
const gridRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const grid = gridRef.current
const body = grid?.querySelector<HTMLElement>('.cal-week-body')
if (view !== 'week' || !grid || !body || grid.offsetParent === null) return
body.scrollTop = (openingMinute(lessons, weekDays(parseDay(anchor, new Date())), new Date()) / 60) * HOUR_PX
// Only when the week or view changes: a lesson moved on this page must not scroll the grid away.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, anchor])

const href = (v: CalView, d: Date) => `/calendar?view=${v}&date=${dayKey(d)}`
const title = view === 'week'
? `${days[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} to ${days[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
Expand Down Expand Up @@ -117,7 +128,7 @@ export default function CalendarView({ view, anchor, lessons, currency = 'USD' }

{view === 'week' ? (
<>
<div className="cal-week dash-card" data-testid="week-grid" role="grid" aria-label="Week">
<div ref={gridRef} className="cal-week dash-card" data-testid="week-grid" role="grid" aria-label="Week">
<div className="cal-week-head" role="row">
<div />
{days.map(d => (
Expand Down
2 changes: 2 additions & 0 deletions src/app/(dashboard)/lessons/[id]/GroupStudentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export function GroupStudentsPanel({
{showAdd && (
<div className="bg-indigo-50 rounded-lg p-3 space-y-2">
<select
aria-label="Student to add"
value={addStudentId}
onChange={e => setAddStudentId(e.target.value)}
className="w-full text-[13px] px-3 py-2 rounded-lg border border-indigo-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
Expand All @@ -122,6 +123,7 @@ export function GroupStudentsPanel({
</select>
<div className="flex gap-2">
<input
aria-label="Price (optional)"
type="number"
placeholder="Price (optional)"
value={addPrice}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function HomeworkSubmissionsPanel({
<div>
<p className="text-[11px] font-medium text-gray-500 mb-1.5">Your feedback</p>
<textarea
aria-label="Feedback for the student"
value={feedbacks[sub.id] ?? ''}
onChange={e => {
setFeedbacks(p => ({ ...p, [sub.id]: e.target.value }))
Expand Down
1 change: 1 addition & 0 deletions src/app/(dashboard)/lessons/[id]/VideoPlatformPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function VideoPlatformPicker({
<div className="space-y-2.5">
<div className="flex gap-2">
<input
aria-label="Meeting link"
value={meetInput}
onChange={e => setMeetInput(e.target.value)}
placeholder="https://meet.google.com/abc-defg-hij"
Expand Down
Loading
Loading