ZHANG.LOG
JavaScriptSupabaseTailwind CSS

Civil Service Exam Notebook

Published 2026-10-18Read Time 5 min

Civil Service Exam Notebook is a note-taking tool I built for my own exam preparation. It has six modules: vocabulary accumulation, word discrimination, political quotations, fixed collocations, calculation formulas, and fragmented knowledge. It works on both desktop and mobile, supports quick capture and search, and lets me flag weak spots with a flame marker. The entire deliverable is one index.html (about 3,200 lines) plus a README: there is no build chain, no framework, and no server of my own, and the data lives in Supabase. I built it through pair programming with an Agent. The focus this time was not the number of features, but how to resolve several problems that only surfaced on real devices.

Why a Single HTML File

I settled the technical form before starting. The requirements were clear: open it on both phones and computers, avoid deployment and operations work, and do not lose data. Compared with the alternatives, a single file plus a BaaS was the leanest form:

RequirementApproach
No deploymentA single index.html; double-click to open, with no build or dependency installation
No data lossSupabase (Postgres) as the backend, connected directly through the JS SDK
Low-maintenance stylingTailwind CDN + Font Awesome, with styles loaded alongside the page
Consistent across devicesThe same file adapts to both desktop and mobile

The cost was equally clear: more than three thousand lines packed into one file, with module boundaries maintained by a dozen or so comment banners (the code architecture in the next section is that map). Any change requires manually confirming that it does not affect anything else. Part of the cost of the later back-button work came directly from this. There was also an explicit tradeoff on the database side: RLS was enabled but every policy was permissive (anon full access), used together with the Supabase publishable key. The README clearly stated that this database was suitable only for personal use and should not be shared publicly: it was a single-user tool, and I did not set up enterprise-grade scaffolding. That opening was later formally closed with the login feature (see “Password Login”).

Code Architecture: Zones Within One File

A single file does not mean a messy pile. The whole file is divided into more than ten functional zones by comment banners, with line numbers serving as anchors:

context
index.html (about 3,200 lines; one file is the entire deliverable)
│
├── <head> styles        L10-69     Tailwind CDN + custom CSS (auto-growing inputs, hidden overlay scrollbars)
├── Page skeleton        L70-193    list areas for six modules, detail/add/edit overlays, settings modal, login page
└── <script> logic       L194-3201
    ├── Supabase setup    L195      createClient: URL and publishable key embedded in the page
    ├── Data storage      L210      reads and writes for six business tables + fallback for weak_items / module_orders
    ├── API config        L302      smart-fill configurations: reads and writes for api_configs
    ├── Config persistence L326     create, update, delete, and selection-state syncing for configurations
    ├── AI fill           L392      dynamic fields for word discrimination, button state machine, fallback tag trimming
    ├── Auto-growing inputs L1043   multiline inputs grow with content (matching CSS in the styles section)
    ├── State management  L1089     current module, sort mode, and weak-item set (entire tables loaded at startup)
    ├── Add-form drafts   L1102     localStorage cache with a 60-second expiry
    ├── Render functions  L1347+    centralized rendering for cards, details, and search results
    ├── Global search     L1464     topic-field matching across five business modules
    ├── Detail overlay    L1715     detail toggle + pushState (L2220) and popstate (L2718) for the back button
    ├── Add / edit / delete L2231   form logic, draft restoration, editing, and deletion
    ├── Settings          L2561     configuration-management modal
    ├── Long-press drag sort L2724  write drag results back to module_orders
    └── Login auth        L3117     password login, session listener, login-page state reset

The benefit of these zones is discoverability: change draft caching and you only enter the L1102 block; change the back button and you only look at L1715, L2220, and L2718; change login and you only enter the L3117 block. After a change, the self-check is limited to that block and does not affect everything else.

Data Model: Six Business Tables and Three Auxiliary Tables

Each of the six modules has its own business table: idioms (vocabulary: word, definition, synonyms, antonyms, common mistakes), word_groups (word discrimination), quotes (political quotations), collocations (fixed collocations), formulas (calculation formulas), and notes (fragmented knowledge). The README imposed only one hard requirement on them: every table must have at least id and created (timestamptz, default now()), while content fields are defined by each module. The direct benefit of keeping the skeleton aligned is that global search, sorting, CRUD, and draft caching all run through one set of logic across six modules instead of being copied six times.

The three auxiliary tables each handle one thing: weak_items stores the IDs of items that “need reinforcement,” which is where the flame on a card comes from, and the entire table is loaded into the weak-item set in memory at startup; api_configs stores configuration for smart fill; module_orders stores each card's manual sort position. The code that reads module_orders has a fallback: if the table is missing or the query fails, it degrades to the default sort without blocking the loading of other modules (index.html:257).

Password Login: Closing the Anonymous Access Gap

The hard prerequisite for anon full access was that this page would open only on my own devices. Once it was deployed, anyone who obtained the project URL and publishable key could read and write all data, including the AI API Key stored in plaintext in api_configs. The updated version closed that gap, using Supabase's built-in authentication to do only three things:

  • The login page asks only for a password. The account email is hard-coded in the LOGIN_EMAIL constant (index.html:3119). A single-user tool has no registration and no multi-user support; the page contains only a password field and a button that shows a spinner.
  • The database policy was tightened in one pass. A do $ script changes all nine tables (the six business tables plus weak_items, api_configs, and module_orders) from “anonymous full access” to “authenticated read/write only,” revokes everything from the anon role, and keeps the complete SQL in the README.
  • Sessions are left to Supabase. onAuthStateChange drives the switch between the login page and the application (index.html:3197). Sessions renew automatically, login is retained after closing and reopening, and the user can log out from the bottom of the sidebar.

The first production version had one loose end: after logging out and returning to the login page, the button still displayed “Logging in…”. Investigation showed that the successful-login path did not restore the button state. The switch was handled by onAuthStateChange, and error paths restored it manually, but the logout path fell through both. The fix (a1a10a0) did not add a third path; it consolidated the reset into one entry point. Every time showLoginPage runs, it first clears the button state, error message, and password field before displaying the page (index.html:3130-3138). Whichever path returns to the login page now shows a clean state. A transition back to the initial state should have only one source in the state machine.

Interaction: Search, Sorting, and Input

  • The card is the detail view. For vocabulary accumulation, synonyms and antonyms appear directly on the card, and common mistakes are moved below the antonyms. This requirement came from actual use: looking up a word should not require another tap.
  • Global search spans five modules and highlights matched keywords.
  • There are three sort modes: manual (by position in module_orders), chronological, and pinyin (a localeCompare(b.pinyin, 'zh') comparison using the pinyin field, index.html:1409). Reverse chronological is the only default; all six modules use the name “Default sort.” In real use, the most recently added notes are the ones I open most often.
  • Long-press and drag a card to reorder it, and write the result back to module_orders so the order remains consistent across devices.
  • Multiline input fields for adding and editing grow automatically: two lines when empty, then one line taller as content increases. The only goal is to keep the full text visible while entering it, without scrolling or truncation.
  • New-item drafts are cached in localStorage (with the gongkao_add_draft_ prefix) and expire after 60 seconds. If I accidentally close the modal, the content is still there when I return (index.html:1103).
  • Details, additions, and edits all open as modal overlays. Scrollbars are hidden, and however long the content is, it scrolls within the overlay's own frame.

AI-Powered Smart Fill: Draft, Don't Finalize

When taking notes in spare moments, sometimes I only want an initial draft. Smart fill uses an OpenAI-compatible chat/completions API, with configurations (endpoint URL, model ID, and API Key) stored in the api_configs table and the currently selected configuration additionally cached in localStorage. When no configuration exists, clicking the fill button prompts me to add one in settings.

Smart fill works on dynamic entries for word discrimination. Click the button to send a request; before the response arrives, the button is locked to prevent duplicate submissions, and its text moves from “AI filling” to “Fill complete, press Enter to add.” The AI result first enters the form, and I decide whether to change it or use it. There is also a fallback: when AI-provided tags exceed the limit, they are automatically trimmed (at most 4 tags and no more than 12 characters in total) so that a good result is not blocked by form validation (index.html:407).

Mobile Back Button: A Failed Attempt and Its Minimal Solution

This is the most worthwhile part of the project to document, because it ended in failure and then landed on a smaller solution.

The requirement came from a real device: after opening card details on a phone and pressing the system back button, I expected the details to close, but the entire page was exited instead. The detail view was an overlay, not a route, so the browser did not know that it was open.

The first version used history.pushState to push “details are open” onto the history stack, then listened for the back action with popstate. This is the version that ultimately remained in the code (index.html:2220 and :2717-2718). On a real device, the detail scenario worked, but pressing back after closing the details still exited the page directly. The extra layer in the history stack disrupted the return order (commit 5967ca3: fix the history-record misalignment when the mobile back button closes details).

The second version used a hash to record the detail state and added a touch-history guard (commit 6c51927). The problem remained: back after closing the list still exited the page, and touch back gestures were so fast that the guard could not distinguish “close details” from “leave the page.”

Finally I reverted it (commit cf089b5) and settled on a minimal solution: keep only the one scenario that worked reliably, where pressing back while details are open closes the details, and explicitly do not support the other scenarios. Remove all special adaptations such as guards. The reason is straightforward: an adaptation that works only on some devices and at some gesture speeds is more dangerous than not supporting it at all. Users think the behavior exists when it does not. The implementation is only about a dozen lines: when opening details, pushState({ detailModalOpen: true }); when closing, if that record is in the history stack, call history.back(); in popstate, if details are still open, close only the details.

The git history preserves the whole process: two rounds of fixes, one revert, and a final commit that consolidated optimizations (d161f2d: add positioning, save and refresh, draft caching, drag sorting, and line wrapping). Looking back, the key judgment was not “how do I fix it?” but “which part is worth fixing?”

Validating Mobile Behavior with Playwright

The single-file app has no automated tests, so mobile validation relied on round-by-round screenshot comparisons in playwright-mcp's phone emulation (390×844, DPR 3). After each round of changes, I captured the detail view, list, and add modal to confirm that layout and interactions had not been affected. This validation layer grew alongside the back-button work: reproduce a real-device problem at least once in the emulator before discussing a fix.

There was also a process incident: one push included the .playwright-mcp debug directory in the remote repository. After discovering it, I cleaned up the directory, added .gitignore, and made “check git status before pushing” a fixed step. Tool byproducts should not enter the repository.

Rebuilding the Repository: Keep Only What Belongs

Late in the project I did a thorough cleanup: deleted the old repository, rewrote the README, ran git init again, committed only the two files index.html and README.md, and pushed them to a new repository. The final commit history contains only 7 commits, each corresponding to a clear stage: initialization, two rounds of back-button fixes, one revert, one consolidated optimization, login launch, and one small login-state fix.

Conclusion

This project's features are not complex, but I deliberately preserved two things. First, the technical form follows the real usage scenario: no deployment, two device classes, and quick capture, without adding a framework just to look sophisticated. Second, failures leave a trace: both rounds of back-button fixes and the revert remain in the git history. The value of the final solution is not that “it was fixed,” but that it clarified “which scenarios are worth fixing.”

On division of labor: I handled requirements, scenario tradeoffs, and real-device validation; the Agent handled implementation, emulator validation, and batch adjustments, with each round's output returning to real-device and screenshot validation. The code repository is private and no public link is currently provided.