fix(sleep,mood): pick the richest writer per night and show the whole note

Sleep nights collapse per writer instead of per source: several
HealthKit apps land behind the same source value, so a phone's coarse
awake and in-bed blocks could blend into or fully constitute a night
whose stages another writer had recorded. The winning writer per
session is the one with the most distinct granular stages, then the
user's source ladder, and its rows are kept whole. The device type is
threaded through every sleep read including the stage stacked bar, and
the sync overlap for late-arriving nights widens from one day to seven
so a night scored after the cursor passed is fetched on the next tick —
assembly is read-time, so affected nights heal without manual repair.
Mood notes expand to their full text in the list on desktop and touch,
replacing the hover-only tooltip, and the mood CSV export carries a
note column.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:52:52 +02:00
1 parent a664395021
commit 657f2323a3
18 files changed
+548 -86

No files matched your search

@@ -60,8 +60,13 @@ describe("mood entries table — long-note truncation", () => {
expect(src).toContain("max-w-[18rem]");
});
it("clamps the note and exposes the full text via a tooltip", () => {
it("clamps the note by default and exposes the full text via the row toggle", () => {
// v1.16.8 — the desktop hover tooltip is replaced by an expandable
// toggle (works on touch, preserves line breaks via pre-wrap). The
// default presentation stays clamped so the table keeps its density.
expect(src).toContain("line-clamp-2");
expect(src).toContain("TooltipContent");
expect(src).toContain("mood-note-toggle");
expect(src).toContain("whitespace-pre-wrap");
expect(src).not.toContain("TooltipContent");
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.16.8 — full-text mood notes on the management list. The note used to be
* readable in full only through the edit sheet (desktop hover tooltip aside,
* unreachable on touch). Each row now carries a toggle that expands the
* clamped two-line preview into the complete text, line breaks preserved,
* rendered as plain React text children (no markup interpretation).
*
* Project convention: SSR-only component tests (`renderToStaticMarkup`) plus
* source-string structural assertions for the interactive branch.
*/
const LONG_NOTE =
"Erster Absatz der Notiz mit etwas längerem Text, der über die Vorschau hinausgeht.\n" +
"Zweiter Absatz mit weiteren Details, die nur in der Vollansicht lesbar sind.";
const baseEntries = [
{
id: "e-1",
date: "2026-05-09",
mood: "GUT",
score: 4,
tags: ["work"],
tagKeys: [],
note: LONG_NOTE,
source: "MANUAL",
moodLoggedAt: "2026-05-09T20:00:00.000Z",
},
{
id: "e-2",
date: "2026-05-08",
mood: "OKAY",
score: 3,
tags: [],
tagKeys: [],
note: null,
source: "MANUAL",
moodLoggedAt: "2026-05-08T20:00:00.000Z",
},
];
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/mood",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { entries: baseEntries, meta: { total: 2 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodList } from "../mood-list";
function render(locale: "en" | "de" = "de") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MoodList />
</I18nProvider>,
);
}
describe("MoodList — full-text note affordance", () => {
it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => {
const html = render("de");
// One toggle per surface (desktop table + mobile card) for the noted row;
// the note-less row renders none.
const toggles = html.match(/data-testid="mood-note-toggle"/g);
expect(toggles?.length).toBe(2);
// Collapsed by default: clamped preview + expand affordance label.
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("line-clamp-2");
expect(html).toContain("Notiz vollständig anzeigen");
// The complete text is in the row (the clamp is visual), both paragraphs.
expect(html).toContain("Erster Absatz der Notiz");
expect(html).toContain("Zweiter Absatz mit weiteren Details");
});
it("localises the affordance label", () => {
const html = render("en");
expect(html).toContain("Show full note");
});
it("expanded branch preserves line breaks and renders plain text children", () => {
const src = readFileSync(
join(process.cwd(), "src/components/mood/mood-list.tsx"),
"utf8",
);
// The expanded branch must keep the author's line breaks…
expect(src).toContain("whitespace-pre-wrap");
// …flip the collapse label…
expect(src).toContain('t("mood.noteCollapse")');
expect(src).toContain('t("mood.noteExpand")');
// …and never interpret the note as markup.
expect(src).not.toContain("dangerouslySetInnerHTML");
});
});
+60 -23
View File
@@ -29,12 +29,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import {
@@ -140,6 +134,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// v1.15.13 — page-scoped multi-select selection (current page ids only).
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
// v1.16.8 — per-entry note expansion. Notes were readable in full only
// through the edit sheet (desktop hover tooltip aside); the row now
// toggles between the clamped preview and the complete text.
const [expandedNoteIds, setExpandedNoteIds] = useState<Set<string>>(
() => new Set(),
);
const toggleNote = (id: string) =>
setExpandedNoteIds((prev) => toggleId(prev, id));
const [editing, setEditing] = useState<MoodEntry | null>(null);
const [editMood, setEditMood] = useState("");
const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({
@@ -599,20 +602,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
: "-"}
</span>
{entry.note && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
{entry.note}
</p>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs whitespace-pre-wrap">
{entry.note}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MoodNoteText
note={entry.note}
expanded={expandedNoteIds.has(entry.id)}
onToggle={() => toggleNote(entry.id)}
/>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
@@ -710,9 +704,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</p>
)}
{entry.note && (
<p className="text-muted-foreground/80 truncate text-xs italic">
{entry.note}
</p>
<MoodNoteText
note={entry.note}
expanded={expandedNoteIds.has(entry.id)}
onToggle={() => toggleNote(entry.id)}
/>
)}
</div>
</div>
@@ -998,6 +994,47 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
);
}
/**
* v1.16.8 — expandable free-text note. Collapsed: a two-line clamped
* preview. Expanded: the COMPLETE text, line breaks preserved, rendered as
* plain text children. The whole block is one toggle button so the full
* note is readable in place — previously the only full-text surface was
* the edit sheet (plus a hover tooltip on desktop, unreachable on touch).
*/
function MoodNoteText({
note,
expanded,
onToggle,
}: {
note: string;
expanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
return (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
data-testid="mood-note-toggle"
className="mt-0.5 block w-full min-w-0 cursor-pointer text-left"
>
<p
className={
expanded
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
: "text-muted-foreground/80 line-clamp-2 text-xs italic"
}
>
{note}
</p>
<span className="text-dracula-purple text-[11px] font-medium">
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</span>
</button>
);
}
/**
* v1.15.13 — render a `MoodEntry.source` value with its localized label.
* MANUAL is shown via the source filter only (a manual row carries no