Web Conventions
Guiding Principle
Section titled “Guiding Principle”Keep apps/web focused on browser concerns and UI composition.
If logic is domain-heavy, reusable across platforms, or tied to collections/entities, it usually belongs in:
@life-manager/db@life-manager/shared
If logic depends on browser APIs, Mantine UI behavior, route state, or local persistence, it usually belongs in apps/web.
Route Split
Section titled “Route Split”- Public localized pages live under
src/routes/$lang/*and usually usereact-i18nextdirectly. - Authenticated app pages live under
src/routes/_app/*and usually format text, dates, and money throughuseIntl()from@life-manager/shared/hooks. src/routes/__root.tsxinitializes i18n globally, so route code should not try to bootstrap it again.
Data Access
Section titled “Data Access”- Prefer
@life-manager/db/queriesfor reads. - Prefer
@life-manager/db/actionsfor mutations and workflows. - Use
src/db/queries/only for thin web-specific wrappers, such asuseProfile()readinguserIdfrom route context. - Reach for
@life-manager/db/coreonly when wiring runtime infrastructure such as PowerSync providers, connectors, collections, or status context.
Zustand Usage
Section titled “Zustand Usage”Use Zustand for state that is specific to the web client and does not belong in the shared database layer.
Current patterns:
settingsStore: user-facing UI preferences, colors, modal state, Pomodoro preferencesworkManagerStore: local work-page selection and modal/filter statecalendarStore: local calendar view state, zoom, selectionfinanceStore: active finance tab and selected bank accounttimeTrackerManagerStore: computed timer runtime snapshot only, not the source of truth for timersrecurringCashflowDevStore: devtools/event store, not normal application state
Do not assume every store should be persisted. Persistence is chosen per store:
settingsStore,workManagerStore,calendarStore, andfinanceStorepersist tolocalStoragecalendarStoreonly persists a partial subsettimeTrackerManagerStoreintentionally stays ephemeralrecurringCashflowDevStoreis a plain Zustand store for diagnostics/events
Time And Scheduling Helpers
Section titled “Time And Scheduling Helpers”Use the right timing primitive for the problem:
- TanStack Pacer is appropriate for debounced UI input.
- Native
setTimeoutandsetIntervalare already used for runtime tasks such as loading transitions, version polling, route prefetching, notification checks, and recurring-cashflow processing.
Avoid turning this into a fake rule like “never use timers”. The current codebase does use them when they are the simplest correct tool.
Forms And Validation
Section titled “Forms And Validation”Mantine Form + Zod is the form foundation. Keep browser-only form behavior in apps/web; keep reusable entity rules in @life-manager/db.
Form Setup
Section titled “Form Setup”Every form uses useForm from @mantine/form with zodResolver from mantine-form-zod-resolver:
import { useForm } from "@mantine/form";import { z } from "zod";import { zodResolver } from "mantine-form-zod-resolver";
const schema = z.object({ title: z.string().min(1, { message: "Required" }), salary: projectType === "hourly" ? z.number().min(0.01) : z.number().optional(),});
const form = useForm<z.infer<typeof schema>>({ initialValues: { title: "", salary: 0 }, validate: zodResolver(schema),});Fields bind via form.getInputProps("fieldName"). Submission uses form.onSubmit(handleSubmit).
Zod schemas can be conditional based on entity state (e.g. project type determines whether salary is required).
Create vs Edit: Unified Form Pattern
Section titled “Create vs Edit: Unified Form Pattern”A single form component handles both create and edit. The entity prop determines the mode:
interface ProjectFormProps { project?: WorkProject; // undefined → create, defined → edit onSuccess?: (p: WorkProject) => void; onCancel?: () => void; // ... related entity state and callbacks}
function handleSubmit(values: z.infer<typeof schema>) { if (project) { await executeUpdate(project.id, values); } else { await executeAdd(values); }}The submit button switches between <CreateButton> and <UpdateButton> based on mode. Both are shared components from src/components/UI/Buttons/.
Modal vs Drawer Convention
Section titled “Modal vs Drawer Convention”- Modals → creating new entities (
NewTimeEntryModal,NewProjectModal) - Drawers → editing existing entities (
EditProjectDrawer,EditTimeEntryDrawer)
Both use Mantine’s stack APIs for nested navigation:
// Modals: useModalsStack for create flowsconst stack = useModalsStack(["time-entry-form", "project-form", "tag-form"]);
// Drawers: useDrawersStack for edit flowsconst drawersStack = useDrawersStack(["edit-project", "delete-confirmation", "tag-form"]);The parent component controls open/close via useEffect:
useEffect(() => { if (opened) { stack.open("main-form"); } else { stack.closeAll(); }}, [opened]);Nested Entity Creation
Section titled “Nested Entity Creation”Forms support creating related entities inline via stacked modals/drawers. The pattern uses callback props:
// Parent (EditProjectDrawer) lifts state for related entitiesconst [tagIds, setTagIds] = useState<string[]>([]);const [contactId, setContactId] = useState<string | null>(null);
// Form receives state + open callbacks<ProjectForm tagIds={tagIds} setTagIds={setTagIds} onOpenTagForm={() => drawersStack.open("tag-form")} contactId={contactId} setContactId={setContactId} onOpenContactForm={() => drawersStack.open("contact-form")}/>
// Nested drawer — on success, updates parent state and closes<Drawer {...drawersStack.register("tag-form")}> <FinanceTagForm onClose={() => drawersStack.close("tag-form")} onSuccess={(tag) => setTagIds([...tagIds, tag.id])} /></Drawer>The SelectWithAdd and MultiSelectWithAdd components (src/components/UI/Input/) render an add-button in the select’s right section that triggers onOpenForm.
Delete And Confirmation
Section titled “Delete And Confirmation”Deletes flow through the guard/workflow system in @life-manager/db and surface as a ConfirmationDrawer in the web UI.
Wiring:
// 1. Set up confirmation drawer in the stackconst drawersStack = useDrawersStack(["edit-project", "delete-confirmation"]);const { confirm, drawerProps } = useConfirmationDrawer(drawersStack, "delete-confirmation");
// 2. Pass confirm to the actionasync function handleDelete() { const result = await executeDelete(id, context, { confirm }); if (result) onClose();}
// 3. Render ConfirmationDrawer<ConfirmationDrawer {...drawerProps} />How it works: The action hook calls the guard system (resolveWorkProjectDelete). Based on the guard result:
DirectResolution→ deletes immediately (no UI needed)ConfirmationResolution→ theconfirmcallback opens theConfirmationDrawerwith title, message, affected items, and severity. Returns aPromise<boolean | string>that resolves when the user confirms or cancelsBlockedResolution→ shows an error notification, deletion prevented
The ConfirmationDrawer supports options (radio buttons for delete strategies), secondary actions, item lists, and severity-based styling (warning/danger).
The delete button in edit drawers is integrated into the DrawerTitle component — it shows a delete icon on the left side of the title bar.
Form Title Components
Section titled “Form Title Components”src/components/UI/Form/FormTitle.tsx provides two components:
ModalTitle— icon + title text for modal headersDrawerTitle— icon or delete button + title for drawer headers;onDeleteprop replaces the icon with aDeleteActionIcon
Shared Button Components
Section titled “Shared Button Components”src/components/UI/Buttons/ provides localized, styled buttons:
CreateButton— green, used for create submissionUpdateButton— blue, used for edit submissionDeleteButton— red, used in confirmation drawersCancelButton— red outline, used alongside submit buttons
Submission Flow
Section titled “Submission Flow”form.onSubmit(handleSubmit)validates with Zod- Handler calls action hook (
executeAdd/executeUpdate/executeDelete) - Action hook runs guards and workflows (may show confirmation)
- Mutation hook executes via
executeMutation()(handles notifications, errors) - PowerSync writes to local SQLite → Supabase syncs in background
- Success callback closes modal/drawer
Notifications And Confirmations
Section titled “Notifications And Confirmations”Collection-level actions in the web app should flow through the web adapters already wired in WebCollectionsProvider:
- toast notifications from
src/lib/toastFunctions.tsx - confirmation UI from
src/db/confirmations/useWebConfirmationAdapter.tsx
This keeps shared workflows UI-agnostic while still giving the web app good browser-native UX.
Settings Navigation
Section titled “Settings Navigation”Use useOpenSettings() when opening settings from feature code:
- on desktop it opens the settings modal
- on mobile it navigates to
/settings
That keeps responsive settings behavior consistent across the app.
Components And Styling
Section titled “Components And Styling”- Components are grouped by feature under
src/components/ - Shared UI primitives live under
src/components/UI/ - Shell-level layout lives under
src/components/AppShell/ - CSS Modules are used for targeted styling where Mantine props are not enough
- Mantine is the primary UI system, but raw elements are acceptable when they are the simplest choice
Imports
Section titled “Imports”Prefer imports from stable package entrypoints:
import { useWorkProjects } from "@life-manager/db/queries";import { useTimeTrackerActions } from "@life-manager/db/actions";import { useIntl } from "@life-manager/shared/hooks";import { getGradientForColor } from "@life-manager/shared";Avoid reaching into deep local paths when an existing package export already expresses the intended dependency.