Cache versioned habit history and load visible calendar ranges
This commit is contained in:
@@ -326,6 +326,34 @@ describe("homepage", () => {
|
|||||||
expect((await f.json("/today")).completed).toBe(2);
|
expect((await f.json("/today")).completed).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
|
||||||
|
const f = connectAccount();
|
||||||
|
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||||
|
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
|
||||||
|
await render("/");
|
||||||
|
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||||
|
expect(calendarRequests()).toHaveLength(2);
|
||||||
|
expect(calendarRequests()[0]).toContain("from=2026-07-01");
|
||||||
|
expect(calendarRequests()[0]).toContain("compact=true");
|
||||||
|
fetchMock.mockClear();
|
||||||
|
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||||
|
expect(calendarRequests()).toHaveLength(0);
|
||||||
|
await f.json(`/habits/${reading.id}/days/2026-09-04/progress`, "PUT", { done: true });
|
||||||
|
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||||
|
expect(calendarRequests()).toHaveLength(1);
|
||||||
|
expect(calendarRequests()[0]).toContain(reading.id);
|
||||||
|
const history = container.querySelector(`#history-${reading.id}`)!;
|
||||||
|
fetchMock.mockClear();
|
||||||
|
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||||
|
expect(calendarRequests()).toHaveLength(1);
|
||||||
|
expect(calendarRequests()[0]).toContain("from=2025-10-01");
|
||||||
|
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
|
||||||
|
expect(calendarRequests()).toHaveLength(0);
|
||||||
|
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
|
||||||
|
});
|
||||||
|
|
||||||
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
||||||
const f = connectAccount();
|
const f = connectAccount();
|
||||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
|||||||
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||||
</article>)}
|
</article>)}
|
||||||
</div>
|
</div>
|
||||||
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
||||||
|
|||||||
27
src/components/CalendarCache.test.ts
Normal file
27
src/components/CalendarCache.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
|
||||||
|
import { CalendarCache } from "./CalendarCache";
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch");
|
||||||
|
afterEach(() => fetchSpy.mockReset());
|
||||||
|
afterAll(() => fetchSpy.mockRestore());
|
||||||
|
const range = { from: "2026-07-01", to: "2026-09-30" };
|
||||||
|
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
|
||||||
|
fetchSpy.mockImplementation(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }));
|
||||||
|
const cache = new CalendarCache();
|
||||||
|
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
|
||||||
|
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
|
||||||
|
expect(subset.days).toHaveLength(1);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
await cache.load("habit", "v2", range);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
await new CalendarCache().load("habit", "v2", range);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
|
||||||
|
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
|
||||||
|
const cache = new CalendarCache();
|
||||||
|
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
|
||||||
|
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
34
src/components/CalendarCache.tsx
Normal file
34
src/components/CalendarCache.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||||
|
import type { CalendarResponse } from "../shared/calendar";
|
||||||
|
import { habitRequest } from "../lib/dashboard";
|
||||||
|
|
||||||
|
type Range = { from: string; to: string };
|
||||||
|
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
|
||||||
|
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
|
||||||
|
export class CalendarCache {
|
||||||
|
private entries = new Map<string, Entry[]>();
|
||||||
|
load(habitId: string, version: string, range: Range) {
|
||||||
|
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
|
||||||
|
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
|
||||||
|
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
|
||||||
|
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
|
||||||
|
entries.push(entry);
|
||||||
|
this.entries.delete(habitId);
|
||||||
|
this.entries.set(habitId, entries.slice(-8));
|
||||||
|
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
|
||||||
|
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const Context = createContext<CalendarCache | null>(null);
|
||||||
|
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
|
||||||
|
const cache = useRef<CalendarCache | null>(null);
|
||||||
|
cache.current ??= new CalendarCache();
|
||||||
|
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
|
||||||
|
}
|
||||||
|
export function useCalendarCache() {
|
||||||
|
const shared = useContext(Context);
|
||||||
|
const local = useRef<CalendarCache | null>(null);
|
||||||
|
local.current ??= new CalendarCache();
|
||||||
|
return shared ?? local.current;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCalendarCache } from "./CalendarCache";
|
||||||
import { Modal } from "./design-system/Modal";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
@@ -40,6 +41,10 @@ export function HabitHistory({
|
|||||||
onHistorySaved?: () => void;
|
onHistorySaved?: () => void;
|
||||||
calendarOnly?: boolean;
|
calendarOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const cache = useCalendarCache();
|
||||||
|
const [range, setRange] = useState(() => monthWindow(date, 3));
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
useEffect(() => { setRange(monthWindow(date, 3)); }, [date]);
|
||||||
const [selectedDate, setSelectedDate] = useState<string>();
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
const [editingDay, setEditingDay] = useState(false);
|
const [editingDay, setEditingDay] = useState(false);
|
||||||
const [savingDay, setSavingDay] = useState(false);
|
const [savingDay, setSavingDay] = useState(false);
|
||||||
@@ -49,24 +54,18 @@ export function HabitHistory({
|
|||||||
const expired = useRef(onExpired);
|
const expired = useRef(onExpired);
|
||||||
expired.current = onExpired;
|
expired.current = onExpired;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
let active = true;
|
||||||
setError("");
|
setError("");
|
||||||
const range = monthWindow(date, 12);
|
setHistoryLoading(true);
|
||||||
habitRequest<CalendarResponse>(
|
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
|
||||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
.then(result => { if (active) setCalendar(result); })
|
||||||
{ signal: controller.signal },
|
.catch(error => {
|
||||||
)
|
if (!active) return;
|
||||||
.then((result) => {
|
if (error.message.includes("session has expired") && expired.current) expired.current();
|
||||||
if (!controller.signal.aborted) setCalendar(result);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
if (error.message.includes("session has expired") && expired.current)
|
|
||||||
expired.current();
|
|
||||||
else setError(error.message);
|
else setError(error.message);
|
||||||
});
|
}).finally(() => { if (active) setHistoryLoading(false); });
|
||||||
return () => controller.abort();
|
return () => { active = false; };
|
||||||
}, [habit.habitId, date, revision, attempt]);
|
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||||
|
|
||||||
const days =
|
const days =
|
||||||
calendar?.days.map((day) => ({
|
calendar?.days.map((day) => ({
|
||||||
@@ -123,6 +122,9 @@ export function HabitHistory({
|
|||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
compact
|
compact
|
||||||
initialMonths={3}
|
initialMonths={3}
|
||||||
|
availableRange={monthWindow(date, 12)}
|
||||||
|
onRangeChange={setRange}
|
||||||
|
loading={historyLoading}
|
||||||
historyLabel="Your recorded progress"
|
historyLabel="Your recorded progress"
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
unit={habit.unit}
|
unit={habit.unit}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
calendarTimeline,
|
calendarTimeline,
|
||||||
MONTH_VIEWS,
|
MONTH_VIEWS,
|
||||||
visibleCalendarDays,
|
visibleCalendarDays,
|
||||||
|
monthWindow,
|
||||||
type CalendarDay,
|
type CalendarDay,
|
||||||
type MonthView,
|
type MonthView,
|
||||||
} from "./calendar-model";
|
} from "./calendar-model";
|
||||||
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
|
|||||||
historyLabel,
|
historyLabel,
|
||||||
legend,
|
legend,
|
||||||
selectedDayAction,
|
selectedDayAction,
|
||||||
|
availableRange,
|
||||||
|
onRangeChange,
|
||||||
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
|
availableRange?: { from: string; to: string };
|
||||||
|
onRangeChange?: (range: { from: string; to: string }) => void;
|
||||||
|
loading?: boolean;
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
unit: string;
|
unit: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
|
|||||||
<div
|
<div
|
||||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||||
data-months={months}
|
data-months={months}
|
||||||
|
aria-busy={loading}
|
||||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="ds-calendar-range-toolbar">
|
<div className="ds-calendar-range-toolbar">
|
||||||
@@ -130,7 +138,7 @@ export function CalendarHeatmap({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||||
aria-pressed={months === value}
|
aria-pressed={months === value}
|
||||||
onClick={() => setMonths(value)}
|
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value)); }}
|
||||||
>
|
>
|
||||||
{value}m
|
{value}m
|
||||||
</button>
|
</button>
|
||||||
@@ -160,7 +168,7 @@ export function CalendarHeatmap({
|
|||||||
const end = form.elements.namedItem("to") as HTMLInputElement;
|
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||||
const isAvailable = (input: HTMLInputElement) =>
|
const isAvailable = (input: HTMLInputElement) =>
|
||||||
input.validity.valid && input.value !== "" &&
|
input.validity.valid && input.value !== "" &&
|
||||||
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
|
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
|
||||||
if (!isAvailable(start) || !isAvailable(end)) {
|
if (!isAvailable(start) || !isAvailable(end)) {
|
||||||
setRangeError("Choose both dates within the available history.");
|
setRangeError("Choose both dates within the available history.");
|
||||||
(!isAvailable(start) ? start : end).focus();
|
(!isAvailable(start) ? start : end).focus();
|
||||||
@@ -171,11 +179,12 @@ export function CalendarHeatmap({
|
|||||||
end.focus();
|
end.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||||
setRangeError("There are no calendar dates in this range.");
|
setRangeError("There are no calendar dates in this range.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCustomRange({ from: start.value, to: end.value });
|
setCustomRange({ from: start.value, to: end.value });
|
||||||
|
onRangeChange?.({ from: start.value, to: end.value });
|
||||||
setRangeError("");
|
setRangeError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -187,8 +196,8 @@ export function CalendarHeatmap({
|
|||||||
name={name}
|
name={name}
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
min={allDays[0]!.date}
|
min={availableRange?.from ?? allDays[0]!.date}
|
||||||
max={allDays.at(-1)!.date}
|
max={availableRange?.to ?? allDays.at(-1)!.date}
|
||||||
defaultValue={customRange[name]}
|
defaultValue={customRange[name]}
|
||||||
aria-invalid={rangeError ? true : undefined}
|
aria-invalid={rangeError ? true : undefined}
|
||||||
aria-describedby={rangeError ? rangeErrorId : undefined}
|
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||||
@@ -201,6 +210,7 @@ export function CalendarHeatmap({
|
|||||||
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
|
||||||
<div
|
<div
|
||||||
className="ds-calendar-viewport"
|
className="ds-calendar-viewport"
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { HabitService } from "../habits/service";
|
import type { HabitService } from "../habits/service";
|
||||||
import type { Schedule } from "../habits/contracts";
|
import type { Schedule } from "../habits/contracts";
|
||||||
|
|
||||||
export type TodayHabit = ReturnType<HabitService["day"]>;
|
export type TodayHabit = ReturnType<HabitService["day"]> & { calendarRevision?: number };
|
||||||
export type TodayResponse = {
|
export type TodayResponse = {
|
||||||
date: string;
|
date: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CalendarCacheProvider } from "../components/CalendarCache";
|
||||||
import { Archive, Plus, Settings2 } from "lucide-react";
|
import { Archive, Plus, Settings2 } from "lucide-react";
|
||||||
import { ArchivedHabits } from "../components/ArchivedHabits";
|
import { ArchivedHabits } from "../components/ArchivedHabits";
|
||||||
import { AccountSettings } from "../components/AccountSettings";
|
import { AccountSettings } from "../components/AccountSettings";
|
||||||
@@ -63,7 +64,7 @@ export function Home() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
) : user ? (
|
) : user ? (
|
||||||
<Dashboard key={user.id} user={user} onExpired={retry} />
|
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
|
||||||
) : (
|
) : (
|
||||||
<Welcome onSignIn={signIn} />
|
<Welcome onSignIn={signIn} />
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +164,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
const [today, setToday] = useState<TodayResponse | null>(null);
|
const [today, setToday] = useState<TodayResponse | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
const [calendarRevision, setCalendarRevision] = useState(0);
|
|
||||||
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
|
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
|
||||||
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
|
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
|
||||||
const progressSaves = useRef(new Set<string>());
|
const progressSaves = useRef(new Set<string>());
|
||||||
@@ -206,7 +206,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
setToday(data);
|
setToday(data);
|
||||||
setNeedsRefresh(false);
|
setNeedsRefresh(false);
|
||||||
setRevision((value) => value + 1);
|
setRevision((value) => value + 1);
|
||||||
setCalendarRevision((value) => value + 1);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -457,7 +456,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
key={habit.habitId}
|
key={habit.habitId}
|
||||||
habit={habit}
|
habit={habit}
|
||||||
date={today.date}
|
date={today.date}
|
||||||
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
|
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
|
||||||
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
|
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
|
||||||
onHistorySaved={() => setAttempt(value => value + 1)}
|
onHistorySaved={() => setAttempt(value => value + 1)}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
|
|||||||
Reference in New Issue
Block a user