Skip to content

Web Conventions

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.

  • Public localized pages live under src/routes/$lang/* and usually use react-i18next directly.
  • Authenticated app pages live under src/routes/_app/* and usually format text, dates, and money through useIntl() from @life-manager/shared/hooks.
  • src/routes/__root.tsx initializes i18n globally, so route code should not try to bootstrap it again.
  • Prefer @life-manager/db/queries for reads.
  • Prefer @life-manager/db/actions for mutations and workflows.
  • Use src/db/queries/ only for thin web-specific wrappers, such as useProfile() reading userId from route context.
  • Reach for @life-manager/db/core only when wiring runtime infrastructure such as PowerSync providers, connectors, collections, or status context.

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 preferences
  • workManagerStore: local work-page selection and modal/filter state
  • calendarStore: local calendar view state, zoom, selection
  • financeStore: active finance tab and selected bank account
  • timeTrackerManagerStore: computed timer runtime snapshot only, not the source of truth for timers
  • recurringCashflowDevStore: devtools/event store, not normal application state

Do not assume every store should be persisted. Persistence is chosen per store:

  • settingsStore, workManagerStore, calendarStore, and financeStore persist to localStorage
  • calendarStore only persists a partial subset
  • timeTrackerManagerStore intentionally stays ephemeral
  • recurringCashflowDevStore is a plain Zustand store for diagnostics/events

Use the right timing primitive for the problem:

  • TanStack Pacer is appropriate for debounced UI input.
  • Native setTimeout and setInterval are 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.

Mantine Form + Zod is the form foundation. Keep browser-only form behavior in apps/web; keep reusable entity rules in @life-manager/db.

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).

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/.

  • 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 flows
const stack = useModalsStack(["time-entry-form", "project-form", "tag-form"]);
// Drawers: useDrawersStack for edit flows
const 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]);

Forms support creating related entities inline via stacked modals/drawers. The pattern uses callback props:

// Parent (EditProjectDrawer) lifts state for related entities
const [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.

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 stack
const drawersStack = useDrawersStack(["edit-project", "delete-confirmation"]);
const { confirm, drawerProps } = useConfirmationDrawer(drawersStack, "delete-confirmation");
// 2. Pass confirm to the action
async 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 → the confirm callback opens the ConfirmationDrawer with title, message, affected items, and severity. Returns a Promise<boolean | string> that resolves when the user confirms or cancels
  • BlockedResolution → 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.

src/components/UI/Form/FormTitle.tsx provides two components:

  • ModalTitle — icon + title text for modal headers
  • DrawerTitle — icon or delete button + title for drawer headers; onDelete prop replaces the icon with a DeleteActionIcon

src/components/UI/Buttons/ provides localized, styled buttons:

  • CreateButton — green, used for create submission
  • UpdateButton — blue, used for edit submission
  • DeleteButton — red, used in confirmation drawers
  • CancelButton — red outline, used alongside submit buttons
  1. form.onSubmit(handleSubmit) validates with Zod
  2. Handler calls action hook (executeAdd / executeUpdate / executeDelete)
  3. Action hook runs guards and workflows (may show confirmation)
  4. Mutation hook executes via executeMutation() (handles notifications, errors)
  5. PowerSync writes to local SQLite → Supabase syncs in background
  6. Success callback closes modal/drawer

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.

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 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

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.