fix(web): reuse sleep projection in agenda timeline
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
|
@ -92,8 +92,8 @@ stream
|
|||
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
|
||||
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
|
||||
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 75 mg ; 22:00 \227 ve) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 522.0 Td (nlafaxine \227 37,5 mg) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
|
||||
0.7 G 30 458 782 40 re S
|
||||
|
|
@ -124,9 +124,9 @@ stream
|
|||
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
|
||||
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
|
||||
0.9 G 612 458 m 612 498 l S
|
||||
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
|
||||
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
|
||||
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
|
||||
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
|
||||
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
|
||||
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
|
||||
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
|
@ -426,6 +426,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
};
|
||||
const sleep=box('sleep-agenda-event-sleep');
|
||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.filter(value => getComputedStyle(value).display !== 'none')
|
||||
.map(value => {
|
||||
const tick=value.getBoundingClientRect();
|
||||
return {
|
||||
|
|
@ -460,9 +461,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
self.assertAlmostEqual(
|
||||
expected_x, geometry[key], delta=1.0, msg=key
|
||||
)
|
||||
agenda_tick_hours = (0, 6, 12, 18, 24)
|
||||
agenda_tick_hours = tuple(range(0, 25, 2))
|
||||
self.assertEqual(
|
||||
["18:00", "00:00", "06:00", "12:00", "18:00"],
|
||||
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
|
||||
[tick["label"] for tick in geometry["ticks"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
|
|
@ -528,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
response = json.loads(bidi.recv())
|
||||
self.assertEqual("success", response["type"])
|
||||
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
|
||||
narrow_ticks = driver.execute_script(
|
||||
"""
|
||||
return [...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.filter(value => getComputedStyle(value).display !== 'none')
|
||||
.map(value => value.textContent);
|
||||
"""
|
||||
)
|
||||
self.assertEqual(["18", "00", "06", "12", "18"], narrow_ticks)
|
||||
self.assertLessEqual(
|
||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||
driver.execute_script("return document.documentElement.clientWidth"),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ if web_assets_enabled
|
|||
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
||||
'../web/src/routes/sleepTimelineGeometry.ts',
|
||||
'../web/src/routes/sleepTimelineGeometry.test.ts',
|
||||
'../web/src/routes/sleepVisualProjection.ts',
|
||||
'../web/src/routes/sleepVisualProjection.test.ts',
|
||||
'../web/src/report/sleepDiaryPdf.ts',
|
||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||
'../web/src/routes/AnalysisPage.test.tsx',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
timelineInterval,
|
||||
timelinePosition,
|
||||
} from '../routes/sleepTimelineGeometry'
|
||||
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
|
||||
import { heartRateVariations } from './heartRateVariation'
|
||||
|
||||
const WIDTH = 1000
|
||||
|
|
@ -96,36 +97,6 @@ function doseLabel(intake: MedicationIntake): string {
|
|||
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
|
||||
}
|
||||
|
||||
interface SleepRange {
|
||||
start: number
|
||||
end: number
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
function sleepRanges(entry: SleepEntry): SleepRange[] {
|
||||
// CONTRACT: recorded Sleep intervals always outrank visualization-only
|
||||
// estimates. The fallback never mutates the entry or claims a medical
|
||||
// inference; incomplete bounds deliberately produce no range.
|
||||
const factual = entry.events
|
||||
.filter((event) => event.type === 'sleep' && event.end_at !== null)
|
||||
.map((event) => ({
|
||||
start: Date.parse(event.start_at),
|
||||
end: Date.parse(event.end_at as string),
|
||||
estimated: false,
|
||||
}))
|
||||
.filter((range) => Number.isFinite(range.start) && range.end > range.start)
|
||||
if (factual.length > 0) return factual
|
||||
|
||||
const bedTime = entry.events.find((event) => event.type === 'bed_time')
|
||||
const finalGetUp = entry.events.find((event) => event.type === 'final_get_up')
|
||||
if (!bedTime || !finalGetUp) return []
|
||||
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000
|
||||
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? [{ start, end, estimated: true }]
|
||||
: []
|
||||
}
|
||||
|
||||
function markerTitle(event: SleepEvent, french: boolean): string {
|
||||
const timelineEvent: HeartRateTimelineEvent = {
|
||||
type: event.type,
|
||||
|
|
@ -180,7 +151,7 @@ export function HeartRateTimelinePanel({
|
|||
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||
}, [timeline])
|
||||
|
||||
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
||||
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
|
||||
|
||||
if (failed) {
|
||||
return <section className="heart-rate-panel" role="alert">
|
||||
|
|
|
|||
|
|
@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => {
|
|||
);
|
||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
||||
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
|
||||
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
|
||||
["12:00", "75%"], ["18:00", "100%"],
|
||||
expect(ticks).toHaveLength(25);
|
||||
expect(
|
||||
ticks
|
||||
.map((tick) => [tick.textContent, tick.style.left])
|
||||
.filter((_, index) => index % 2 === 0),
|
||||
).toEqual([
|
||||
["18", "0%"],
|
||||
["20", `${(2 / 24) * 100}%`],
|
||||
["22", `${(4 / 24) * 100}%`],
|
||||
["00", "25%"],
|
||||
["02", `${(8 / 24) * 100}%`],
|
||||
["04", `${(10 / 24) * 100}%`],
|
||||
["06", "50%"],
|
||||
["08", `${(14 / 24) * 100}%`],
|
||||
["10", `${(16 / 24) * 100}%`],
|
||||
["12", "75%"],
|
||||
["14", `${(20 / 24) * 100}%`],
|
||||
["16", `${(22 / 24) * 100}%`],
|
||||
["18", "100%"],
|
||||
]);
|
||||
expect(ticks[1]).toHaveClass("sleep-hour-tick-odd");
|
||||
expect(ticks[2]).toHaveClass("sleep-hour-tick-even");
|
||||
expect(ticks[6]).toHaveClass("sleep-hour-tick-major");
|
||||
});
|
||||
|
||||
it("shows the shared estimated sleep range only when factual sleep is absent", async () => {
|
||||
const estimated = {
|
||||
...dayAEntry(),
|
||||
entry_id: "sl_estimated",
|
||||
revision_id: "slr_estimated",
|
||||
night_start_date: "2026-09-23",
|
||||
night_end_date: "2026-09-24",
|
||||
events: [
|
||||
{
|
||||
event_id: "bed",
|
||||
type: "bed_time" as const,
|
||||
start_at: "2026-09-23T22:05:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
{
|
||||
event_id: "up",
|
||||
type: "final_get_up" as const,
|
||||
start_at: "2026-09-24T04:14:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
],
|
||||
intakes: [],
|
||||
};
|
||||
api.fetchSleepDiary.mockResolvedValue(snapshotWith([estimated]));
|
||||
api.fetchSleepMedications.mockResolvedValue([medication]);
|
||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
|
||||
const range = await screen.findByTestId("sleep-agenda-sleep-estimated");
|
||||
expect(range).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Sommeil estimé 22:50 → 04:04",
|
||||
);
|
||||
expect(Number.parseFloat(range.style.left)).toBeCloseTo((4.833333333 / 24) * 100, 7);
|
||||
expect(Number.parseFloat(range.style.width)).toBeCloseTo((5.233333333 / 24) * 100, 7);
|
||||
expect(screen.queryByTestId("sleep-agenda-event-sleep")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("isolates an empty editor night while retaining the period agenda", async () => {
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import {
|
|||
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||
import {
|
||||
sleepAgendaTicks,
|
||||
sleepTimelineInterval,
|
||||
sleepTimelinePosition,
|
||||
} from "./sleepTimelineGeometry";
|
||||
import { sleepDisplayRanges } from "./sleepVisualProjection";
|
||||
|
||||
type Language = "fr" | "en";
|
||||
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||
|
|
@ -161,6 +163,12 @@ const copy = {
|
|||
} as const;
|
||||
|
||||
const clock = (iso: string) => (iso ? iso.slice(11, 16) : "");
|
||||
const projectedClock = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const medicationLabel = (medication: SleepMedication) =>
|
||||
medication.default_dose_value === null
|
||||
? medication.name
|
||||
|
|
@ -717,12 +725,22 @@ export function SleepDiaryWorkspace({
|
|||
<div className="sleep-axis-row" aria-hidden="true">
|
||||
<span />
|
||||
<span className="sleep-hour-axis">
|
||||
{[0, 6, 12, 18, 24].map((index) => (
|
||||
{sleepAgendaTicks().map((tick) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{ left: `${(index / 24) * 100}%` }}
|
||||
key={tick.hourOffset}
|
||||
className={[
|
||||
"sleep-hour-tick",
|
||||
tick.hourOffset % 2 === 0
|
||||
? "sleep-hour-tick-even"
|
||||
: "sleep-hour-tick-odd",
|
||||
tick.hourOffset % 6 === 0
|
||||
? "sleep-hour-tick-major"
|
||||
: "",
|
||||
].join(" ")}
|
||||
data-hour-offset={tick.hourOffset}
|
||||
style={{ left: `${tick.position * 100}%` }}
|
||||
>
|
||||
{String((18 + index) % 24).padStart(2, "0")}:00
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
|
@ -730,6 +748,9 @@ export function SleepDiaryWorkspace({
|
|||
</div>
|
||||
{agendaEntries.map((entry) => {
|
||||
const facts = sleepEntryFacts(entry);
|
||||
const estimatedSleep = sleepDisplayRanges(entry).filter(
|
||||
(range) => range.estimated,
|
||||
);
|
||||
const groupedIntakes = Object.entries(
|
||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||
(groups, intake) => {
|
||||
|
|
@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({
|
|||
</small>
|
||||
</strong>
|
||||
<span className="sleep-track">
|
||||
{estimatedSleep.map((range) => {
|
||||
const geometry = sleepTimelineInterval(
|
||||
new Date(range.start).toISOString(),
|
||||
new Date(range.end).toISOString(),
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
);
|
||||
const label = `${
|
||||
language === "fr" ? "Sommeil estimé" : "Estimated sleep"
|
||||
} ${projectedClock(range.start)} → ${projectedClock(range.end)}`;
|
||||
return (
|
||||
<i
|
||||
key={`estimated-${range.start}-${range.end}`}
|
||||
className="sleep-event sleep-sleep sleep-estimated"
|
||||
data-testid="sleep-agenda-sleep-estimated"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{
|
||||
left: `${geometry.left * 100}%`,
|
||||
width: `${geometry.width * 100}%`,
|
||||
}}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{entry.events.map((event) => {
|
||||
const geometry = event.end_at
|
||||
? sleepTimelineInterval(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hourlyTimelineTicks,
|
||||
sleepAgendaTicks,
|
||||
sleepTimelineInterval,
|
||||
sleepTimelinePosition,
|
||||
timelineInterval,
|
||||
|
|
@ -70,4 +71,27 @@ describe("sleep timeline geometry", () => {
|
|||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("derives exact one-hour and two-hour 18:00-to-18:00 ticks", () => {
|
||||
const hourly = sleepAgendaTicks();
|
||||
expect(hourly).toHaveLength(25);
|
||||
expect(hourly[0]).toEqual({ hourOffset: 0, position: 0, label: "18" });
|
||||
expect(hourly[6]).toEqual({ hourOffset: 6, position: 0.25, label: "00" });
|
||||
expect(hourly[24]).toEqual({ hourOffset: 24, position: 1, label: "18" });
|
||||
expect(sleepAgendaTicks(2).map((tick) => tick.label)).toEqual([
|
||||
"18",
|
||||
"20",
|
||||
"22",
|
||||
"00",
|
||||
"02",
|
||||
"04",
|
||||
"06",
|
||||
"08",
|
||||
"10",
|
||||
"12",
|
||||
"14",
|
||||
"16",
|
||||
"18",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,26 @@ export interface TimelineTick {
|
|||
position: number;
|
||||
}
|
||||
|
||||
export interface SleepAgendaTick {
|
||||
hourOffset: number;
|
||||
position: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function sleepAgendaTicks(stepHours = 1): SleepAgendaTick[] {
|
||||
if (!Number.isInteger(stepHours) || stepHours < 1 || 24 % stepHours !== 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from({ length: 24 / stepHours + 1 }, (_, index) => {
|
||||
const hourOffset = index * stepHours;
|
||||
return {
|
||||
hourOffset,
|
||||
position: hourOffset / 24,
|
||||
label: String((18 + hourOffset) % 24).padStart(2, "0"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function timelinePosition(
|
||||
timestamp: string | number,
|
||||
start: string | number,
|
||||
|
|
|
|||
94
web/src/routes/sleepVisualProjection.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SleepEntry } from "../api/sleepDiary";
|
||||
import { sleepDisplayRanges } from "./sleepVisualProjection";
|
||||
|
||||
const entry = (events: SleepEntry["events"]): SleepEntry => ({
|
||||
entry_id: "sl_11111111-1111-4111-8111-111111111111",
|
||||
night_start_date: "2026-09-23",
|
||||
night_end_date: "2026-09-24",
|
||||
created_at: "2026-09-23T22:05:00+02:00",
|
||||
updated_at: "2026-09-24T04:14:00+02:00",
|
||||
revision_id: "slr_11111111-1111-4111-8111-111111111111",
|
||||
sleep_quality: null,
|
||||
wake_quality: null,
|
||||
day_form: null,
|
||||
treatment_and_notes: "",
|
||||
events,
|
||||
intakes: [],
|
||||
publication_status: "ready",
|
||||
});
|
||||
|
||||
describe("sleep visual projection", () => {
|
||||
it("always prefers structured sleep ranges", () => {
|
||||
const ranges = sleepDisplayRanges(
|
||||
entry([
|
||||
{
|
||||
event_id: "bed",
|
||||
type: "bed_time",
|
||||
start_at: "2026-09-23T22:05:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
{
|
||||
event_id: "sleep",
|
||||
type: "sleep",
|
||||
start_at: "2026-09-23T22:20:00+02:00",
|
||||
end_at: "2026-09-24T04:00:00+02:00",
|
||||
},
|
||||
{
|
||||
event_id: "up",
|
||||
type: "final_get_up",
|
||||
start_at: "2026-09-24T04:14:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(ranges).toEqual([
|
||||
{
|
||||
start: Date.parse("2026-09-23T22:20:00+02:00"),
|
||||
end: Date.parse("2026-09-24T04:00:00+02:00"),
|
||||
estimated: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("shares the conservative 22:50 to 04:04 fallback", () => {
|
||||
const ranges = sleepDisplayRanges(
|
||||
entry([
|
||||
{
|
||||
event_id: "bed",
|
||||
type: "bed_time",
|
||||
start_at: "2026-09-23T22:05:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
{
|
||||
event_id: "up",
|
||||
type: "final_get_up",
|
||||
start_at: "2026-09-24T04:14:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(ranges).toEqual([
|
||||
{
|
||||
start: Date.parse("2026-09-23T22:50:00+02:00"),
|
||||
end: Date.parse("2026-09-24T04:04:00+02:00"),
|
||||
estimated: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invent an estimate with a missing bound", () => {
|
||||
expect(
|
||||
sleepDisplayRanges(
|
||||
entry([
|
||||
{
|
||||
event_id: "bed",
|
||||
type: "bed_time",
|
||||
start_at: "2026-09-23T22:05:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
34
web/src/routes/sleepVisualProjection.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { SleepEntry } from "../api/sleepDiary";
|
||||
|
||||
export interface SleepDisplayRange {
|
||||
start: number;
|
||||
end: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
export function sleepDisplayRanges(entry: SleepEntry): SleepDisplayRange[] {
|
||||
// CONTRACT: recorded Sleep intervals always outrank visualization-only
|
||||
// estimates. The fallback never mutates or enters the canonical entry, so
|
||||
// agenda, HR detail, export, and synchronization cannot confuse it with a
|
||||
// factual sleep event.
|
||||
const factual = entry.events
|
||||
.filter((event) => event.type === "sleep" && event.end_at !== null)
|
||||
.map((event) => ({
|
||||
start: Date.parse(event.start_at),
|
||||
end: Date.parse(event.end_at as string),
|
||||
estimated: false,
|
||||
}))
|
||||
.filter((range) => Number.isFinite(range.start) && range.end > range.start);
|
||||
if (factual.length > 0) return factual;
|
||||
|
||||
const bedTime = entry.events.find((event) => event.type === "bed_time");
|
||||
const finalGetUp = entry.events.find(
|
||||
(event) => event.type === "final_get_up",
|
||||
);
|
||||
if (!bedTime || !finalGetUp) return [];
|
||||
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000;
|
||||
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000;
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? [{ start, end, estimated: true }]
|
||||
: [];
|
||||
}
|
||||
|
|
@ -714,6 +714,9 @@ h2 {
|
|||
transform: translateX(-50%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sleep-hour-tick-odd {
|
||||
display: none;
|
||||
}
|
||||
.sleep-hour-axis > span:first-child {
|
||||
transform: none;
|
||||
}
|
||||
|
|
@ -823,6 +826,11 @@ h2 {
|
|||
transparent 3px 6px
|
||||
);
|
||||
}
|
||||
.sleep-estimated {
|
||||
opacity: 0.72;
|
||||
outline: 1px dashed var(--teal);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.sleep-long_awake {
|
||||
border: 2px solid var(--peach);
|
||||
background: transparent;
|
||||
|
|
@ -845,6 +853,16 @@ h2 {
|
|||
color: var(--crust);
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
.sleep-hour-tick-odd {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.sleep-hour-tick-even:not(.sleep-hour-tick-major) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.sleep-editor-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
|
|
|||