Implement Fare v2 p1 #95
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#95
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add GTFS Fares V2 support for the three "simple" tables:
fare_media.txt,fare_products.txt, andrider_categories.txt. The GTFS spec files and TypeScript entity types are already defined in the codebase; what was missing was the database schema registration and all UI. The approach is a modal-based CRUD interface accessible from a new "Fares" section on the Browse home page. The modal supports add/edit/delete for all three tables with schema-driven field rendering (tooltips, presence asterisks, spec links) matching the rest of the app.Relevant Context
Files modified on this branch:
src/modules/gtfs-database.ts— added three new IDB store registrations and indexes (schema v9)src/modules/fares-modal.ts— new file (~800 lines), full CRUD modal for all three V2 tablessrc/modules/page-content-renderer.ts— added Fares section to home page, wired toshowFaresModalsrc/modules/modal-utils.ts— added optionalboxClassNametoshowModalfor wider modalsKey utilities used:
generateFieldConfigsFromSchema(schema, data, tableName)+renderFormFields(configs)insrc/utils/field-component.ts— drives all form field rendering with tooltips/asterisks/spec linksgenerateCompositeKeyFromRecord('fare_products', record)insrc/utils/gtfs-primary-keys.ts— builds the IDB key string for the composite-keyfare_productstableGTFS_ENUMSinsrc/types/gtfs-enums.ts— auto-derived from specenumValues, sofare_media_typeandis_default_fare_containerautomatically render as selectsArchitecture note: The fares modal does NOT use the form-patch-bridge. Fields are rendered with
data-fieldattributes (for visual consistency), but values are read manually in each Save handler and routed throughpatchManager.record*()directly.Phase 1: Database Schema Registration
Register the three new tables in the IndexedDB schema.
dbVersionfrom8to9insrc/modules/gtfs-database.ts| 'rider_categories' | 'fare_media' | 'fare_products'toGTFSStoreNameunionGTFSDBSchemainterfaceRiderCategories,FareMedia,FareProductsfromgtfs-entitiesaddIndexesForTable()for FK lookups onfare_productsand name indexes onfare_media/rider_categoriesDiscovery: Version bump does a clean-slate wipe of all IDB stores (intentional per project's no-migration policy).
Phase 2: Fares Modal UI
Create
src/modules/fares-modal.tswith a single exportedshowFaresModal(deps)function. Three DaisyUI tab panels, one per table. Each tab shows a list of existing records with add/edit/delete.FaresModalDepsinterfacerenderRiderCategoriesPanel,renderFareMediaPanel,renderFareProductsPanel— table of rows with edit/delete buttons per row and "Add" button at topshowAddEditRiderCategoryModal,showAddEditFareMediaModal,showAddEditFareProductModal— nestedshowModal()with form HTML, validation, andpatchManager.record*()callsshowDeleteConfirmModal— nested confirmation dialogshowFaresModal— tabs + event delegation +refreshPanel()helperDiscovery:
fare_productscomposite key fields (fare_product_id, rider_category_id, fare_media_id) are disabled during edit since changing them would create key mismatch in IDB. Users must delete and re-add to change key fields.noImplicitReturnstsconfig rule required explicitreturn;at end of each asynconClickhandler.Phase 3: Entry Point on Home Page
renderHome()inpage-content-renderer.tswith a "Manage Fares (V2)" button.fares-btnclick toshowFaresModalinaddEventListeners()showFaresModalinpage-content-renderer.tsPhase 4: Polish — Abstracted Field Rendering, Bigger Modal, V2 Note
Bring the fares forms in line with how all other browse panels render GTFS fields. The rest of the app uses
generateFieldConfigsFromSchema+renderFormFieldsfromsrc/utils/field-component.ts, which auto-generates labels with tooltips, colored presence asterisks (*), and spec links. The original modal used hardcoded HTML form fields with no tooltips or asterisks.boxClassName?: stringtoshowModaloptions insrc/modules/modal-utils.ts; append it to themodal-boxdiv classfares-modal.ts: updatereadFormValuesto query by[data-field="${field}"]instead of[name="${field}"](required becauserenderFormFieldoutputsdata-field, notname)generateFieldConfigsFromSchema,renderFormFields,FieldConfigfromfield-component.js;GTFSSchemas,GTFS_TABLESfromgtfs.js;type { z }fromzodshowAddEditRiderCategoryModalto usegenerateFieldConfigsFromSchema(GTFSSchemas[GTFS_TABLES.RIDER_CATEGORIES], existing ?? {}, GTFS_TABLES.RIDER_CATEGORIES)+renderFormFieldsshowAddEditFareMediaModalsimilarly withGTFS_TABLES.FARE_MEDIA—fare_media_typeauto-renders as a select viaGTFS_ENUMSshowAddEditFareProductModal: generate base configs fromGTFS_TABLES.FARE_PRODUCTS, then post-processrider_category_idandfare_media_idto custom selects (populated from loaded rows); setreadonly: isEditon those two FK fields in edit mode (they're part of the composite key but not markedisPrimaryKeyin the spec)boxClassName: 'max-w-3xl'to the outershowFaresModalcall for wider modal<p class="text-xs text-base-content/60 mb-3">Supports a limited set of Fares V2: rider categories, fare media, and fare products. More tables coming soon.</p>Discovery:
GTFS_ENUMSis auto-derived from specenumValuesviaderiveGTFSEnumsinadapter.ts, sofare_media_typeandis_default_fare_containerare automatically in the enum registry and render as selects without any manual registration.Phase 5: Fares Table Column Header Tooltips
The three panel tables in
fares-modal.tsuse plain hardcoded<th>text with no tooltips, presence marks, or spec links. The column headers should use the samerenderFieldLabelContent(config)fromsrc/utils/field-component.tsthat the rest of the app uses for labeled fields, so hovering a column header shows the field description, presence level, and spec link.Relevant context:
renderFieldLabelContent(config: FieldConfig)insrc/utils/field-component.ts— renderslabel + presence markwrapped in a tooltip; already exportedgenerateFieldConfigsFromSchema(schema, {}, tableName)— call with empty data{}to get configs for all fields (no value needed for headers)GTFSSchemas[GTFS_TABLES.RIDER_CATEGORIES | GTFS_TABLES.FARE_MEDIA | GTFS_TABLES.FARE_PRODUCTS]— schemas already imported infares-modal.ts<th></th>) stays as-is (no field)Steps:
renderColumnHeader(fieldName: string, configs: FieldConfig[]): stringinfares-modal.tsthat finds the matching config byfieldand returns<th>${renderFieldLabelContent(config)}</th>, falling back to<th>${fieldName}</th>if not foundrenderRiderCategoriesPanel: callgenerateFieldConfigsFromSchemawith therider_categoriesschema and{}at the top of the function to build aconfigsarray; replace the hardcoded<th>ID</th><th>Name</th><th>Default</th>withrenderColumnHeadercalls forrider_category_id,rider_category_name,is_default_fare_containerrenderFareMediaPanel: same pattern forfare_media_id,fare_media_name,fare_media_typerenderFareProductsPanel: same pattern forfare_product_id,fare_product_name,rider_category_id,fare_media_id,amount,currencyrenderFieldLabelContentfromfield-component.js(already importsgenerateFieldConfigsFromSchema,renderFormFields,FieldConfig— addrenderFieldLabelContentto that import)Gotcha:
renderRiderCategoriesPanel,renderFareMediaPanel, andrenderFareProductsPanelare currently pure functions with no schema imports — keep them pure by computing configs inline at the top of each function, not via a shared module-level variable.Phase 6: Fares Navbar Button
Remove the "Fares" section from the home page (
page-content-renderer.ts) and replace it with a ticket icon button in the navbar (src/index.html), wired up insrc/index.ts.Relevant context:
src/index.htmllines 66–end of<div class="navbar-end">. The Files button (id="files-btn") is the pattern to follow:btn btn-ghost btn-sm btn-square hidden md:flexwith atitleattribute and an SVG icon.fares-btninrenderHome()insrc/modules/page-content-renderer.tsand wired inaddEventListeners()in the same fileshowFaresModalis imported and called inpage-content-renderer.ts; after this change it moves tosrc/index.tsticketoutline icon (24×24 viewBox) that fits the existing btn-square patternsrc/index.tsis where all module wiring happens;GTFSEditorclass owns the top-level event listenersSteps:
src/index.html: add a new<button id="fares-btn" class="btn btn-ghost btn-sm btn-square" title="Fares (V2)">with a ticket SVG icon, placed before the Files button inside<div class="navbar-end">src/modules/page-content-renderer.ts: remove the entire Fares<section>block fromrenderHome()and remove the.fares-btnclick listener fromaddEventListeners(); remove theshowFaresModalimportsrc/index.ts: importshowFaresModalfrom./modules/fares-modal.js; in the post-construction wiring section, add a click listener ondocument.getElementById('fares-btn')that callsshowFaresModalwith the appropriategtfsDatabaseandpatchManagerdeps (same deps already passed topage-content-renderer— look at howpatchManageris accessed there)Gotcha: The navbar button should always be visible (not gated on a loaded feed), consistent with how the Files button works. If no feed is loaded the modal just shows empty tables with "No X yet" messages, which is fine.
Phase 7: Fix ID Field Editable on Add
The ID fields (
rider_category_id,fare_media_id,fare_product_id) are rendered as disabled in both Add and Edit mode becausegenerateFieldConfigsFromSchemaunconditionally setsreadonly: isPrimaryKey(seesrc/utils/field-component.tsline 618). The correct behavior is: readonly during Edit (can't change a primary key in place), but writable during Add (the user must supply the new ID).Relevant context:
generateFieldConfigsFromSchemainsrc/utils/field-component.ts— always marks the primary key field withreadonly: true; this is correct for edit but wrong for addshowAddEditRiderCategoryModal,showAddEditFareMediaModal,showAddEditFareProductModalinsrc/modules/fares-modal.ts— each callsgenerateFieldConfigsFromSchemathen maps the result to addrecordId; the post-processing step is wherereadonlymust be fixedfare_products,rider_category_idandfare_media_idare already post-processed as custom selects withreadonly: isEdit; onlyfare_product_idneeds the fix hereSteps:
showAddEditRiderCategoryModal: after the.map((c) => ({ ...c, recordId }))call, chain another.mapthat setsreadonly: isEditfor therider_category_idfield (i.e.,c.field === 'rider_category_id' ? { ...c, readonly: isEdit } : c)showAddEditFareMediaModal: same pattern forfare_media_idshowAddEditFareProductModal: same pattern forfare_product_id(therider_category_idandfare_media_idoverrides that follow already setreadonly: isEditcorrectly)Gotcha:
readFormValuesreads bydata-fieldattribute;renderFormFieldsrenders disabled inputs with thedisabledHTML attribute (notreadonly). A disabled input's value is not included in form submissions but IS readable viael.valuein JS — soreadFormValueswill still pick up the value. No changes needed toreadFormValues. Verify the ID value is still read correctly after the fix by checking thatvals.rider_category_idis non-empty on save for the add path.Original Issue
The files associated with GTFS-Fares V2 are:
P2 (later):
Lets start by adding a simple modal that allows adding products, categories, and media.
almost perfect, double check fields and make sure we show everything (including age in tables). Maybe better way to show an overview?