Build Zenkit
A personal work manager whose hard problems are letting a list grow new fields without a schema migration every time, keeping every view a query rather than a second copy of the truth, and expanding recurring items correctly across February and a clock change.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the single process that serves the app.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree
Why Needed to inspect the schema, confirm that adding a field ran no migration, and run a restore by hand.
Get it Your package manager, or sqlite.org. open ↗
Verify
sqlite3 --version prints a version - have readyfree
Why The recurrence subset is copied from the specification rather than invented, and the rules about invalid dates and about count with an end date come straight from it.
Get it Read the section and write down which parts of the rule you support. open ↗
Verify
A documented list of supported rule parts exists before any recurrence code - decidefree
Why Each kind needs a typed column, a sort, a filter and a form control, and adding a kind later touches all four, so the list is a decision rather than a discovery.
Get it Write down the kinds you will support and the column each one writes into.
Verify
The list is in the README and matches the values table columns
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- lists(id, name, icon, position, created_at, archived_at) - fields(id, list_id, key, label, kind, options_json, position, required) · kind is text, number, date, datetime, select, multi_select, checkbox or person - items(id, list_id, title, status, position, created_at, updated_at, completed_at, deleted_at, parent_item_id) - field_values(item_id, field_id, text_value, num_value, date_value, datetime_utc, zone) · one typed column populated per row, never everything as text - views(id, list_id, name, shape, filter_json, sort_json, group_by_field_id) · shape is table, board, calendar or list - recurrences(id, item_id, rule_text, dtstart, zone, count_done, ends_at) · the rule, not a pile of generated rows - activity(id, item_id, at, kind, before_json, after_json) · append-only, and the source of undo - A view owns no items. It owns a filter, a sort and a grouping field, so the board and the table cannot disagree about what is true.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/work.db | The whole workspace. This is the file to back up. |
BACKUP_DIR | required | ./data/backups | Where the backup interface writes copies, and where a restore is rehearsed from. |
DEFAULT_TIMEZONE | required | Europe/London | Used to expand recurrence rules and to render all-day dates. |
PORT | required | 4180 | Bound to localhost. There is no authentication because there is one user. |
TRASH_RETENTION_DAYS | required | 30 | How long a deleted item stays recoverable before purge offers to remove it. |
The build, in order
One item table that survives a new field
A user adds a field to a list without a migration and without losing the ability to sort or filter on it.
One text column for everything is what makes sorting and filtering wrong later.
Files
src/db/schema.sqlsrc/fields.jsThe user invents fields faster than you ship releases.
Files
src/fields.jstests/fields.test.jsNine against ten, and March against May.
Files
tests/sorting.test.js
done when · tick each as it passeswatch out- The obvious shape stores every custom value as text in one column, and then a number field sorts 10 before 9, a date filter compares strings so March sorts before May, and an empty value and the string 'null' become the same thing. Give the values table a column per kind, write into exactly one of them, and index the column the field actually uses.
Views are saved queries, never second copies
The same items shown as a table, a board and a calendar always agree.
A view that caches rows is a second truth that drifts by Friday.
Files
src/views.jsThe board is a grouping, so moving a card is an update.
Files
src/views.jssrc/public/board.jsField deletion is common and should not produce a blank page.
Files
src/views.jstests/views.test.js
done when · tick each as it passeswatch out- Building the board as its own table of cards with their own positions means moving an item in the table view and moving it on the board write to two different places, and within a week the two views disagree about which column a task is in with no way to tell which is right. Make the board a grouping over the item rows, so dragging a card is an update to one field.
Recurring items that survive February
A repeating task generates the right dates across short months, clock changes and a completed occurrence.
Adding a month to a date is how the 31st becomes fiction.
Files
src/recurrence.jstests/recurrence.test.jsSection 3.3.10 is explicit that they are ignored, not rounded.
Files
src/recurrence.jsTicking Tuesday off should not end the habit.
Files
src/items.jstests/occurrences.test.js
done when · tick each as it passeswatch out- Generating the next date by adding one month to the previous one turns the 31st of January into an invented 31st of February, or quietly shifts a monthly task to the 28th forever once it lands there. Expand from the rule and the original start each time, and drop invalid dates the way the specification says rather than rounding them into the nearest real day.
Capture in three seconds, and undo
Adding a task is faster than opening a form, and every destructive action is reversible.
Before and after values, because undo has to know both.
Files
src/activity.jsA single-user app has no colleague with a copy.
Files
src/items.jstests/trash.test.jsCapture that needs navigation does not get used.
Files
src/public/capture.js
done when · tick each as it passeswatch out- Hard deleting an item on a mis-click is unrecoverable in a single-user app because there is nobody to ask for a copy, and the user discovers it a week later when the task never came back. Delete by timestamp, keep the row, and put purge behind its own screen with a count of what is about to go.
Everything out, and back in againproduct builder
The workspace can leave this app intact and return without loss.
An export nobody has imported is a guess about the future.
Files
src/export.jstests/roundtrip.test.jsCommas, quotes and newlines live in every notes field.
Files
src/csv.jstests/csv.test.jsCopying the file under load produces a copy that opens until it matters.
Files
src/backup.jsdocs/restore.md
done when · tick each as it passeswatch out- Copying a SQLite file with cp while the process is running can capture a database mid-transaction along with a stale write-ahead log, and the copy opens fine until the day you actually need it. Use the backup interface or a documented checkpoint, and restore one copy into a scratch directory as part of the routine rather than trusting it.
That is the whole plan for Zenkit. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Multiplayer editing and permissions.
- Email or push notifications.
- A mobile application.
- Automations and third-party integrations.
- enterprise permissions
- large integration catalog
- mobile capture and notifications
- real-time multiplayer
- A read-only shared link for one view, which needs a token and no permission model
- Subtasks rolled up into a parent item, once the filter language can express it
- A weekly digest generated locally and saved as a file rather than sent
Need the files? The project pack on the verdict page hands your agent the whole brief · more tasks.