-**Identity** — each field has a stable, randomly generated ID. The ID never changes; label, type constraints (e.g., required), default value, and options are mutable in place.
-**Historical entries** — because entries reference fields by ID, renaming a field retroactively re-labels old values; entries are not snapshotted. Old values are never re-validated against changed constraints — existing entries are not required to stay valid when a field definition changes.
-**Deletion** — hard delete. Removing a field definition cascades and removes all stored values for that field across all time entries. The parent time entry rows themselves remain.
-**Hybrid auto-import on project creation.** When a project is created, the current set of org-level fields is copied into the project in the same DB transaction as project creation. Each copy gets a new field ID; the link to the org field is not retained.
-**Manual import later.** A project admin can re-open the import dialog from project settings and choose any subset of current org fields to copy in. Same snapshot semantics — fresh IDs, no live link.
After import, project fields are fully independent. Editing or deleting the org-level source field does not affect copies already in projects. Adding new org-level fields does not retroactively touch existing projects.
Modal with a checkbox list of every current org-level field plus a "Select all" toggle. Rows whose label already exists on the project (case-insensitive trimmed match) show an inline `Already in project` warning chip but remain checkable — re-importing produces a fresh, independent copy.
The timesheet entry form is rendered as the existing **Dialog** when the project has zero custom fields configured. The moment a project has ≥1 custom field, both create and edit switch to dedicated routes:
Rationale: custom-field count is unbounded, dialog height becomes unpredictable, and a page provides natural room. The form body is one shared component wrapped by either the Dialog or the page.
v1 ships **display only**. Custom values are persisted, rendered on the form, and shown read-only on entry detail/list views. No filter UI, no group-by, no aggregation. Filterable/groupable reports are deferred (the JSON storage choice acknowledges this cost upfront — see
Fields render in `createdAt` ascending order, both in settings UIs and on the timesheet form. No `position` column is required on field-definition tables.
Custom values render inline read-only via `CustomValuesInline`, a shared component used on both internal surfaces (timesheet list/detail rows for members and admins) and client-facing shared timesheet reports (`ReportCard` with
Constraint edits (label, required, default, dropdown options, etc.) are always allowed at the definition level. Existing entries are not required to stay valid when a definition changes. Validation is **enforce-on-write, never on read**:
-**When it changes:** moments re-render in the new zone (they are UTC instants); [[calendar-date-vs-moment|calendar dates]] do not move (zoneless). Nothing is rewritten.
The user-supplied data for one custom field on one time entry. Values are stored on `time_entries` in a single `custom_values jsonb` column shaped
**
organization
**
level (Saturn's term; the issue uses "workspace" synonymously) or the
**
project
**
level.
`
time_entries.project_id
`
). Projects acquire fields in two ways:
`
YYYY-MM-DD
`
.
`
…Z
`
), displayed in viewer's locale.
`
HH:MM
`
(24h). No TZ.
`
select
`
/
`
date
`
/
`
datetime
`
/
`
time
`
— a literal value, or null (no default).
— viewer's local calendar date at render time.
— viewer's local wall-clock time at render time.
— viewer's local moment at render time, stored as its UTC equivalent.
Timesheet
**
and grows a second card,
**
Custom fields
**
, beneath the existing timesheet defaults card. No new top-level tab is added.
[
Custom Field Value
]
(
#custom-field-value
)
).
`
visibleToClient = true
`
on artifacts shared with them.
`
visibleToClient = true
`
are exposed to clients — the field list is filtered at query time (
`
getProjectCustomFields
`
adds
`
visibleToClient = true
`
for the
`
client
`
role) before rendering. Invoices do not render custom values in v1.
`
viewerRole='client'
`
). On the client surface the rendered set is already restricted to opted-in fields by the query-time filter above, so
`
CustomValuesInline
`
itself needs no client-specific gating. The v1 treatment is a simple inline append to the entry's description cell, not a dedicated UX. Invoice rendering of custom values is deferred.
*
field definition.
`
minutes
`
.
`
quantity = durationMinutes / 60
`
(e.g. 90m →
`
1.5
`
),
`
unitPrice = hourlyRate
`
per hour.
`
quantity = durationMinutes
`
(whole integer, e.g.
`
90
`
),
`
unitPrice = hourlyRate / 60
`
rounded to 4 decimals (the per-minute rate).
`
amount
`
or the invoice total. See
[
Import amount accuracy
]
(
#import-amount-accuracy
)
.
**
organization
**
and
**
project
**
scope. Resolution is
**
project row if present, else org row, else
`
hours
`
**
— the same cascade as the other invoice/timesheet defaults (
`
projectOrOrgSettings
`
). It only
*
seeds
*
the import — the creator can override per-import in the dialog. Surfaced in a dedicated
**
Invoice import defaults
**
card on both org and project settings.
`
ImportTimeEntriesDialog
`
, opened from the invoice editor. Carries a
`
Hours | Minutes
`
segmented toggle (seeded from the default, overridable). Per-entry rows show real durations via
`
formatMinutes
`
; the aggregate "selected" badge reflects the chosen unit (
`
90m selected
`
/
`
1.5h selected
`
).
`
autoImportTime
`
effect that fires when an invoice is created from a timesheet (
`
fromTimesheet
`
). No toggle; uses the resolved default. Its description suffix reflects the unit (
`
member — 90m
`
/
`
member — 1.5h
`
).
**
(
`
durationMinutes / 60 × hourlyRate
`
, rounded to 2 dp), independent of the displayed unit or the rounded per-minute
`
unitPrice
`
. This keeps invoice totals identical regardless of the import unit and free of per-minute rounding drift. Note: the invoice editor recomputes
`
amount = quantity × unitPrice
`
(4 dp) when a user manually edits a row, so the duration-derived value is authoritative only until a row is hand-edited — sub-cent and invisible at the PDF's 2-dp display.
**
, stored as a single
`
timezone
`
text column on the
`
users
`
table (added via better-auth
`
additionalFields
`
, then
`
bun auth:generate
`
). It is a property of the human, not of an organization or project — every user, including
**
clients
**
, carries their own. A US-based client stores
`
America/New_York
`
; a Lisbon contractor billing them stores
`
Europe/Lisbon
`
; each sees dates in their own zone.
`
members
`
) or on the
`
settings
`
cascade — timezone is location, not workspace config.
`
users
`
table through better-auth
`
additionalFields
`
(
`
bun auth:generate
`
). A mounted
`
TimezoneSync
`
client component (
`
src/components/timezone-sync.tsx
`
) reads the browser zone (
`
Intl.DateTimeFormat().resolvedOptions().timeZone
`
) for any authenticated viewer and writes it via
`
updateTimezoneAction
`
whenever it differs from the stored value — capturing it right after sign-up and following the user if their zone later changes. There is no form field. (The action uses the base
`
actionClient
`
, not
`
authedActionClient
`
, because a just-signed-up user has no active organization yet.)
`
TimezoneSync
`
runs, and pre-existing users until their next authenticated load) renders moments in
**
UTC
**
— never the server's incidental local zone.
`
time_entries.date
`
,
`
member_rates.effective_from
`
,
`
invoices.issueDate
`
,
`
invoices.dueDate
`
,
`
expenses.date
`
,
`
milestones.dueDate
`
,
`
projects.dueDate
`
,
`
proposals.validUntil
`
). Zoneless: the
**
same literal day for every viewer
**
. "Due June 1" reads June 1 in every timezone — a deadline is not relative to where the viewer stands. Never zone-shifted. (Same treatment as the custom-field
`
date
`
type.)
`
createdAt
`
,
`
sentAt
`
,
`
signedAt
`
,
`
respondedAt
`
,
`
completedAt
`
, etc., and the custom-field
`
datetime
`
type). Rendered in the
**
viewer's
**
timezone.
`
new Date(...)
`
→ UTC midnight, then locale formatting renders the day before for viewers west of UTC. The fix for those is a storage/format change,
is dropped and the value comes from the client's local date. Otherwise a creator in Auckland at 9 AM gets the server's (UTC) yesterday.
`
date
`
; an invoice shows its
`
issueDate
`
/
`
dueDate
`
; etc. So:
`
date
`
columns).
`
createdAt
`
,
`
sentAt
`
, …) are shown as their
*
date in the viewer's timezone
*
. The wall-clock time is not surfaced, so there is no author-zone display, no hover conversion, and no per-row timezone snapshot. The viewer's [
[
user-timezone
]
] only decides which calendar day a UTC instant falls on for that viewer.
`
{fieldId: value}
`
(value is the raw scalar — string, number, boolean, or ISO date/time depending on the field type). Validation lives in the app layer (Zod) using the field definition. Deleting a field removes its key from every entry's
`
custom_values
`
via a single
`
UPDATE … SET custom_values = custom_values - $fieldId