A browser-based task manager for creating and editing tasks, assigning users, setting priorities and due dates, marking tasks complete, and deleting tasks. Drag and drop tasks to reorder them. Due-date colors highlight upcoming and missed deadlines, and overdue tasks have a pastel pink background. If your device is set to reduce motion, overdue tasks are also marked with a red border. A task whose due date is cleared shows "No due date" in the normal colour. Deadlines follow the current date while the page stays open, with no need to reload.
Tasks are saved in this browser using localStorage and restored on refresh. The sample tasks appear on your first visit, with due dates relative to that day. "Reset to sample tasks", beside the Assignee dropdown, replaces the current tasks with a fresh sample set at once, without asking for confirmation. If browser storage is unavailable, tasks remain usable in memory, but changes will not persist.
Use the Assignee dropdown above the task list to show one user's tasks or "All assignees". Both completed and incomplete matching tasks remain visible. "All assignees" appears first, followed by users alphabetically by name. Each task's assignee dropdown uses the same alphabetical user order. Your selection is restored on refresh; a missing or unavailable assignee defaults to "All assignees". If storage is unavailable, the selection remains usable in memory. New tasks are assigned to the selected assignee. Under "All assignees", they default to the first available user. Dragging filtered tasks changes their order in the complete task list.
Dictate into a task title with the microphone button beside it, or press Ctrl and Alt together (with no other key) while editing a title. Press either again to stop. Recognised speech is inserted at the cursor, replacing any selected text, with a space added where it meets existing words. Words appear as you speak and may be corrected for a few seconds, including just after you stop. Moving the cursor, or moving to another task title, sends further speech there. Dictation stops automatically after a period without recognised speech (15 seconds by default), after a maximum time (60 seconds by default, counted from when the microphone starts recording), when focus leaves the task titles, or when the task being dictated is deleted. Recognised text may keep settling for up to four seconds after dictation stops. A line at the top of the page says how to start and stop dictating.
A status box above the task list, between the assignee filter and "Reset to sample tasks", shows what dictation is doing:
- "Connecting" from when you start until recognition is ready.
- "Listening" while speech is accepted, with a level meter beside it.
- "Finishing" once recording has stopped, while recognised text may still arrive. The microphone is no longer recording.
- Afterwards, why dictation stopped, or what went wrong, such as a blocked microphone.
The level meter shows how loud the sound reaching the microphone is. It does not show whether speech was recognised, and a quiet meter is not an error. "Level meter" above "Reset to sample tasks" chooses its style, and can be changed while dictating: "Horizontal bar" grows from the left, with a marker that holds the recent peak for about a second, "Analogue needle" sweeps a needle across an arc, and "Waveform" draws a row of thin vertical bars. "Look", on the line below, offers three looks for the style that is selected: Solid, Segments and Gradient for the bar, Classic, Zones and Ticks for the needle, and Pill, Pulse and Columns for the waveform. Pill and Columns scroll the last second or so of sound from right to left, Pill as white bars in a black pill and Columns as coloured bars rising from the bottom. In Pulse the bars stay in place and swell together. Each style keeps its own look. The choices last until the page is reloaded. The level is worked out in the browser from the audio already being captured, and is not stored or sent anywhere. With reduced motion set on the device, the meter moves in a few steps a second.
A line above the task list says "This demo may retain dictated text for diagnostics.", because the deployed app keeps the final recognised text; see Stored transcripts. Dictation needs setting up first; see Set up voice dictation.
- React and TypeScript for the interface and application logic.
- Vite for local development and production builds.
- React DnD with the HTML5 backend for drag-and-drop reordering.
- date-fns for date calculations and formatting.
- Speechmatics realtime speech to text for dictation, using the official
@speechmatics/real-time-client,@speechmatics/browser-audio-inputand@speechmatics/authpackages. - Vercel for hosting the deployed app and its dictation endpoints.
With Node.js 20.19+ (20.x), 22.13+ (22.x), or 24+, and npm installed, run this from the project directory:
npm installnpm run devOpen the local URL printed in the terminal.
Dictation uses the Speechmatics realtime API. Without this setup the app works as normal and the dictation buttons show that dictation is unavailable.
-
Create an API key in the Speechmatics portal.
-
Create
.env.localin the project directory. It is ignored by git, so the key is never committed:SPEECHMATICS_API_KEY=your-api-key
-
Restart
npm run dev(ornpm run preview) after changing.env.local.
The key stays on the local Vite server. The browser only receives temporary keys
that expire after 60 seconds, from /api/dictation/token. Locally these
endpoints are served by npm run dev and npm run preview. The deployed app
serves the same endpoints as hosted functions; see Deployment.
Other dictation settings live in dictation.config.toml in the project
directory. It is committed, so it must never contain the API key, and each
setting is explained by a comment in the file:
| Setting | Default | Purpose |
|---|---|---|
[speechmatics] model |
"enhanced" |
Speechmatics model, such as "enhanced" or "standard". |
[speechmatics] language |
"en" |
Language code, such as "de", or a bilingual pack such as "cmn_en". |
[speechmatics] realtime_url |
"wss://eu.rt.speechmatics.com/v2" |
Realtime endpoint, for example the us or au region. |
[timings] inactivity_timeout_seconds |
15 |
Stop after this many seconds without recognised speech. Sound that produces no recognised words does not count. |
[timings] max_session_seconds |
60 |
Maximum length of one dictation session, from when the microphone starts recording. |
[timings] settle_timeout_seconds |
4 |
How long recognised text may keep settling after dictation stops. |
One timing is fixed in the code and not in the file: connecting to Speechmatics may take up to 15 seconds from when the microphone starts recording. After that, dictation stops and reports that the service is unavailable.
Restart npm run dev or npm run preview after changing it. The server checks
the file when it starts and stops with a message naming the problem, such as a
misspelt setting or a timing that is not a positive number.
To change the model, language or endpoint on your machine only, set
SPEECHMATICS_MODEL, SPEECHMATICS_LANGUAGE or SPEECHMATICS_RT_URL in
.env.local; these override the file.
To try multilingual dictation with the Melia 1 realtime preview, set the model
to melia-1, the language to multi and the realtime URL to
wss://preview.rt.speechmatics.com/v2. Speechmatics provides
the preview for evaluation only, not production use, and it does not yet support
the custom dictionary that helps recognise the app's user names.
Microphone access needs a secure context, so open the app on localhost or over
HTTPS. The browser asks for microphone permission the first time you dictate.
The local server must be able to reach mp.speechmatics.com, and the browser
must be able to reach the realtime endpoint.
The app is deployed on Vercel as one project: the built front end, and the dictation endpoints as Vercel Functions on the same address. The browser connects directly to Speechmatics for realtime dictation, so the host only issues temporary keys.
- Public address: https://task-manager-three-phi-83.vercel.app/
- The functions are
api/dictation/status.ts,api/dictation/token.ts,api/dictation/activity.tsandapi/dictation/transcript.ts. All four run the handler that local development uses, inserver/dictationHandler.ts. - Vercel reads the Speechmatics API key from the
SPEECHMATICS_API_KEYenvironment variable in the project's settings. The key must never be committed to the repository or exposed in browser code. The same key can be used for local development and the deployment. If the key is missing, the deployed app remains usable but dictation is unavailable. - The other dictation settings come from
dictation.config.toml, whichvercel.jsondeploys with the functions.SPEECHMATICS_MODEL,SPEECHMATICS_LANGUAGEandSPEECHMATICS_RT_URLcan be set in Vercel to override it, as they can locally. - Vercel compiles the functions with the options in the root
tsconfig.json. - Visits and page views are reported to Vercel Web Analytics, which must be
enabled for the project in Vercel. It uses no cookies. Under
npm run devit only logs to the browser console and records nothing. - A Neon Postgres database holds two things: dictation activity events, one
row per event, and the complete final transcript of each dictation session,
one row per session. The functions reach it through the
DATABASE_URLenvironment variable, which Vercel sets when the database is connected to the project. See Stored activity and Stored transcripts. - The endpoints are public and have no access control. Usage is limited by the Speechmatics account's credit.
To update the deployed app, push to the branch that Vercel deploys as production. Vercel builds and deploys it automatically. After changing an environment variable in Vercel, redeploy for it to take effect. Running the app locally needs no Vercel account or tools.
The app reports when dictation is used, so that activity on the deployed app
can be seen afterwards. The browser sends an event name and a testing label to
/api/dictation/activity. The server writes one line of JSON to its log and,
when deployed, saves the same three values to a database:
{"type":"dictation_activity","event":"dictation_transcript_received","user":"Dad","timestamp":"2026-10-08T15:30:00.000Z"}| Event | What it shows |
|---|---|
dictation_started |
The microphone was allowed and Speechmatics accepted the session. |
dictation_transcript_received |
Speechmatics recognised at least one word. Logged once per session. |
dictation_completed |
A started session ended normally: stopped by the user or by an automatic stop. |
dictation_failed |
A session ended with an error, before or after it started. |
Transcript text, audio, keys, tokens and error messages are never written to the log. For activity events the server accepts only the four event names above and a valid testing label, and ignores everything else in a request. The final recognised text is kept separately; see Stored transcripts. Reporting is best-effort: if it fails, dictation carries on and nothing is retried.
To see the log, open the project in Vercel, choose Logs, and search for
dictation_activity. The Hobby plan keeps runtime logs for one hour, so the
log shows recent activity only. The database is the lasting record.
The deployed app saves each event to a Neon Postgres
database, added to the Vercel project through Vercel's Storage tab. Vercel
gives the functions its connection string as the DATABASE_URL environment
variable. That string is a secret: it stays on the server, and must never be
committed or used in browser code.
Each activity row holds only the event name, the testing label and the time the server received it. No audio, key, token, IP address, user-agent or error message is stored anywhere.
One-time setup, after creating the database and connecting it to the project:
- In Vercel, open the Storage tab, select the database, and open it in Neon.
- In Neon's SQL Editor, paste the contents of
server/dictationActivity.sqland run it. It creates thedictation_activitytable, and is safe to run again. The app never creates the table itself. - Redeploy the project so that the functions pick up
DATABASE_URL.
To inspect stored activity, use Neon's Tables view, or run a query in its SQL Editor:
-- The most recent events.
select occurred_at, user_label, event
from dictation_activity
order by occurred_at desc
limit 100;
-- Events per day, per label.
select occurred_at::date as day, user_label, event, count(*)
from dictation_activity
group by 1, 2, 3
order by 1 desc, 2, 3;Things to know:
- Saving is best-effort. If the database is unreachable, slow, or its table is
missing, the event is still written to the log, a line with the type
dictation_activity_store_failedand at most a database error code is logged, and dictation is unaffected.42P01there means the table has not been created. - At most 1,000 rows are stored in any 24 hours. Events beyond that are logged but not stored. This limits what a flood of requests to the public endpoint can add.
- The endpoint is public, so anyone can send a valid event with any valid label. Stored activity is an indication of use, not proof of it.
- Testing labels are names you choose, kept with the times they were used
until you delete them. To remove old rows, run for example
delete from dictation_activity where occurred_at < now() - interval '90 days'; - Without
DATABASE_URL, as when running locally, events are only logged and no database connection is attempted.npm run devandnpm run previewnever store events.
The deployed app also keeps the words that Speechmatics recognised, so that you can see what a demonstration session produced. The app tells its users so, with the line "This demo may retain dictated text for diagnostics." above the task list.
- One transcript is kept for each dictation session. The browser gathers the session's final results in the order they arrive and joins them as the app does when it displays them.
- Interim results, which change as someone speaks, are never part of it, and audio is never sent to the server.
- The transcript is sent once, when the session has settled: after dictation has stopped and the settling period for late final results is over. A session that failed or was stopped early still sends the final results it had. A session with no final results sends nothing.
- It is sent to
/api/dictation/transcriptwith the text in the request body, so that it does not appear in request logs, and saved as one row in thedictation_session_transcriptstable of the same Neon database. - A row holds the text, the testing label, the time the server received it, and a random identifier for the dictation session. The identifier is made afresh for each session and is not stored in the browser, so it does not identify a visitor.
- The text is never written to the server log or the browser console. The server logs one line per transcript with its label, session and length.
- A session is stored once: a repeated request for a session that already has a transcript changes nothing.
- What is kept is what Speechmatics recognised, not the title as later edited.
One-time setup: in Neon's SQL Editor, run the contents of
server/dictationTranscripts.sql. It adds the dictation_session_transcripts
table and is safe to run again. It changes nothing that exists, so the
dictation_activity table and any rows in dictation_transcripts are left
as they are. Until it has been run, transcripts are not stored, the log shows
dictation_activity_store_failed with code 42P01, and dictation is
unaffected.
To read stored transcripts:
-- Complete transcripts, most recent first.
select occurred_at, user_label, transcript
from dictation_session_transcripts
order by occurred_at desc
limit 50;An earlier version of the app stored each final result as its own row in
dictation_transcripts. Nothing writes to that table now. To read what it
holds, joined up by session:
select min(occurred_at) as started, user_label,
string_agg(transcript, ' ' order by sequence) as said
from dictation_transcripts
group by session_id, user_label
order by started desc;Things to know:
- This is other people's speech. It can contain names or anything else a
visitor chose to say, held in a third-party database until you delete it.
Keep it only as long as it is useful, for example
delete from dictation_session_transcripts where occurred_at < now() - interval '30 days'; - A transcript is cut to 4,000 characters, several times what a 60-second session produces, and at most 1,000 are stored in any 24 hours.
- If the page is closed before the session has settled, which takes up to four seconds after dictation stops, that session's transcript is not sent.
- The endpoint is public, so anyone can send text to it under any valid label. Stored text is not proof that it was spoken into the app.
- Saving is best-effort, as for activity: a failure is logged without the text and never affects dictation.
- Without
DATABASE_URL, and undernpm run devandnpm run preview, transcripts are discarded unread.
A testing label names the browser that dictation was used from, so that your own testing, and that of people you ask to try the app, can be told apart from other visits. It is stored in that browser only, and has no control in the app.
- Set a label: open the app with
?user=NAMEadded to the address, for examplehttps://your-address/?user=Robin. It stays set for later visits without the parameter. Opening the app with a different name replaces it. - Clear the label: open the app with
?user=PUBLIC. - Check it: in the browser console,
localStorage.getItem("taskmanager.activityUser")shows the label, ornullwhen none is set.
A label is 1 to 20 letters, digits, hyphens or underscores. Anything else is ignored and leaves the current label as it was.
A label in the address applies to that page straight away and takes precedence
over a stored one. It does not depend on the browser being able to save it. If
the browser cannot save it, as in some private browsing modes and browsers
embedded in other apps, the label lasts only for pages opened with it in the
address, so use the full ?user= link each time on such a browser.
Events from a labelled browser are logged with that label as user. All
others are logged with "user":"PUBLIC", which means only that no label was
set in that browser. It does not confirm an unknown visitor: your own phone, a
private window, or any browser where you have not set a label all count as
PUBLIC. A label is a convenience and not proof of who someone is, because
anyone can open the app with any label. Giving someone a link that includes
?user= labels their dictation activity with that name.
- Open the public address in a fresh browser window that is not signed in to anything, and confirm it loads without a sign-in prompt.
- Dictate into a task title and confirm the words appear as you speak.
- Stop dictation, start it again, then refresh the page and confirm the text is still there.
- Check the remaining credit in the Speechmatics portal.
- Confirm in Vercel that the production deployment is the intended commit.
| Command | Description |
|---|---|
npm run dev |
Start the Vite development server. |
npm run dev:browser-check |
Start the isolated server that browser checks run against. |
npm run build |
Check TypeScript and create a production build in dist/. |
npm run lint |
Check the code with ESLint. |
npm test |
Run the unit tests once. |
npm run test:watch |
Rerun the unit tests as files change. |
npm run preview |
Serve the production build locally; run npm run build first. |
Tickets are issues in this project's GitHub repository, and AGENTS.md has the
rules for them. Outside GitHub a ticket is written with the prefix TM, such as
TM #17.
- TaskManager's tickets: https://github.com/robinmckenzie/TaskManager/issues
- All Robin's open tickets, across projects: https://github.com/issues?q=is%3Aissue+is%3Aopen+user%3Arobinmckenzie
Tickets were files under tickets/ until 2026-10-11. Each of those is now the
issue numbered ten higher, with its old ID in the title: old TM-1 is TM #11.
Claude Code can open the app in a real browser, use it, and report what it saw.
It does this through the
Playwright MCP server, which is
registered for this project in .mcp.json. Nothing is added to package.json:
the server is fetched by npx the first time it is used, and npm install,
npm run dev, npm run build, npm run lint and npm test work the same
without it. AGENTS.md says when Claude runs browser checks and the rules it
follows.
.mcp.jsonstarts@playwright/mcpat exactly version 0.0.83, through the launcherserver/startPlaywrightMcp.mjs:- Playwright MCP also takes settings from environment variables, and an
option in
.mcp.jsononly overrides the setting it names. An inherited variable could otherwise load saved cookies (PLAYWRIGHT_MCP_STORAGE_STATE), attach to a browser that is already running (PLAYWRIGHT_MCP_CDP_ENDPOINT), or read another configuration or secrets file (PLAYWRIGHT_MCP_CONFIG,PLAYWRIGHT_MCP_SECRETS_FILE), whatever--isolatedsays. - The launcher refuses to start the server if any variable beginning
PLAYWRIGHT_,PW_,PWTEST_,PWMCP_orPWDEBUGis set, and names the variable without showing its value. This includes harmless ones such asPLAYWRIGHT_BROWSERS_PATH. If/mcpshowsplaywrightas failed, start Claude Code from a shell without those variables.
- Playwright MCP also takes settings from environment variables, and an
option in
- The server drives the Google Chrome installed on this computer, in a visible
window, with these options:
--isolatedkeeps the browser profile in memory, so it starts empty, is discarded when the browser closes, and never touches your own profile.--allowed-origins http://127.0.0.1:5183limits the browser's requests to the browser-check server. Playwright MCP documents this as a guardrail and not a security boundary, and it does not apply to redirects.--block-service-workersstops a page from installing a service worker.--output-dir .playwright-mcpis where screenshots and other files go. Git ignores that folder.
- It is not given the options that would connect to a browser that is already
running (
--cdp-endpoint,--extension) or that would allow files outside the project to be read (--allow-unrestricted-file-access). .claude/settings.jsondenies thebrowser_run_code_unsafetool, which Playwright MCP describes as equivalent to running arbitrary code on this computer.npm run dev:browser-checkstarts a separate server for the checks, usingvite.browser-check.config.ts, athttp://127.0.0.1:5183:- It loads no
.envfiles, so it never reads the Speechmatics key in.env.local. Dictation shows as unavailable. - It refuses to start if the shell has a Speechmatics, database or Vercel variable set, and names the variable without showing its value.
- It fails if port 5183 is taken, instead of moving to another port.
- It has its own address, so its saved tasks are separate from those of
npm run dev, which can stay running. - It keeps its Vite dependency cache in
node_modules/.vite-browser-check, separate from thenode_modules/.vitecache thatnpm run devuses, so neither server rebuilds a cache the other is serving from. - Like
npm run dev, it never connects to the database.
- It loads no
The version was checked against the npm registry on 2026-10-08, when 0.0.83 was the latest release. Each release depends on a pre-release build of Playwright, which is how Microsoft publishes it. There is no stable alternative, so the version is fixed here instead of following the newest release.
- Install Google Chrome if it is not already installed.
- Start a new Claude Code conversation in this project. Claude Code reads
.mcp.jsonwhen a conversation starts and asks you to approve theplaywrightserver the first time. - Type
/mcpand check thatplaywrightshows as connected. The first start downloads the package, so it can take a little while.
Ask Claude to run a browser check of the app. It should start the browser-check
server, open http://127.0.0.1:5183 in a new Chrome window that has none of
your bookmarks or sign-ins, and describe a screenshot of the task list.
While that address is restricted, two things the page normally fetches from elsewhere are blocked: the assignee photos and the Vercel Web Analytics debug script. Missing photos and console messages about those two requests are expected during a browser check.
- Check that the shell has no Speechmatics, database, Vercel or Playwright variable set, by listing names only, and that nothing is listening on port 5183.
- Start
npm run dev:browser-checkas a process that can be stopped on its own. Confirm that it listens on127.0.0.1:5183and thatGET /api/dictation/statusreturns{"configured":false}. - Open
http://127.0.0.1:5183in the browser that Playwright MCP provides, and use "Reset to sample tasks" before the first check. - When the checks are done, close the browser with the Playwright MCP close tool, then stop only the browser-check server. Confirm that port 5183 is free and that Git shows no changes.
Things to know while checking:
- Persistence. The browser profile is temporary and is discarded when the browser closes. To check that a change is saved, reload the page in the same browser session before closing it.
- Drag-and-drop. Drop on the lower half of the target task when moving a task down, and on the upper half when moving it up. A drop on the exact centre of a task does not reorder, because the app moves a task only once the pointer crosses the middle of the task it is over.
- Blocked requests.
ERR_BLOCKED_BY_CLIENTconsole errors fori.pravatar.ccandva.vercel-scripts.comare expected, and their number varies as tasks are added, reassigned and reloaded. Treat any other console error or failed request as a finding. - Assignee photos. They are blocked, so their appearance cannot be checked this way. Each shows as a broken image with the assignee's name.
- Dictation. The server has no Speechmatics key, so the dictation buttons are disabled. Checking that they are disabled is in scope. Live dictation stays a manual check.
- Dates and sliders. Enter a due date as
yyyy-mm-dd, whatever format the page displays. A priority slider can be set directly, or by clicking it and pressing the arrow keys. - Files. Save screenshots and snapshots under
.playwright-mcp/, with names that say what they show. Playwright MCP also writes a page snapshot and a console log there on its own. Git ignores the folder.
- Delete
.mcp.json,.claude/settings.json,vite.browser-check.config.ts,server/browserCheckEnvironment.tsand its test,server/startPlaywrightMcp.mjs,server/playwrightMcpEnvironment.mjsand its test, thedev:browser-checkscript inpackage.json, and thevite.browser-check.config.tsentry and theallowJsandcheckJsoptions intsconfig.node.json. - Delete the
.playwright-mcpfolder if it exists. Optionally deletenode_modules/.vite-browser-check, the browser-check server's dependency cache, which is otherwise removed along withnode_modules. - To remove the downloaded package, delete its folder from the
npxcache, which is under%LOCALAPPDATA%\npm-cache\_npxon Windows.
To switch it off without removing it, reject the playwright server when
Claude Code asks, or run claude mcp reset-project-choices to be asked again.
Codex does not read .mcp.json. It takes MCP servers from ~/.codex/config.toml
or from a project .codex/config.toml, so it would need an equivalent entry
there. That has not been set up.
.codex/rules/validation.rules allows npm test, npm run lint, and
npm run build to run outside the sandbox without repeated approval prompts.
Keep workspace-write sandbox protection and on-request approvals enabled; other
commands needing execution outside the sandbox still require approval.
Trust this project in Codex and restart Codex after adding or changing the rules.
The allowances apply to TaskManager project sessions, not unrelated project sessions. They match command prefixes, including trailing arguments, and do not restrict the working directory of each matching command. They trust the project scripts and their subprocesses. Remove the rules and restart Codex to revoke these allowances; avoid saving them as user-global permissions.