98417046bc
CI / update (push) Has been cancelled
Floor fertile/peak windows at the prior period's end + 1 day so a short cycle + long period combo can't predict peak fertility starting during or right after bleeding. Future cycles also widen the outer fertile range using observed shortest/longest cycle (Ogino-style), keeping the peak band narrow around the mean ovulation estimate.
1905 lines
56 KiB
Svelte
1905 lines
56 KiB
Svelte
<script>
|
||
import { m } from '$lib/js/fitnessI18n';
|
||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||
import Plus from '@lucide/svelte/icons/plus';
|
||
import Pencil from '@lucide/svelte/icons/pencil';
|
||
import UserPlus from '@lucide/svelte/icons/user-plus';
|
||
import X from '@lucide/svelte/icons/x';
|
||
import Check from '@lucide/svelte/icons/check';
|
||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||
import DatePicker from '$lib/components/DatePicker.svelte';
|
||
import { toast } from '$lib/js/toast.svelte';
|
||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||
|
||
/**
|
||
* @type {{ periods: any[], lang: 'en' | 'de', sharedWith?: string[], readOnly?: boolean, ownerName?: string, mode?: 'entry' | 'projection' | 'full' }}
|
||
*/
|
||
let { periods: initialPeriods = [], lang = 'en', sharedWith: initialSharedWith = [], readOnly = false, ownerName = '', mode = 'full' } = $props();
|
||
const t = $derived(m[lang]);
|
||
const showEntry = $derived(mode !== 'projection');
|
||
const showProjection = $derived(mode !== 'entry');
|
||
|
||
// svelte-ignore state_referenced_locally
|
||
let periods = $state([...initialPeriods]);
|
||
let loading = $state(false);
|
||
let showAddForm = $state(false);
|
||
let addStart = $state('');
|
||
let addEnd = $state('');
|
||
|
||
// Edit state
|
||
let editId = $state('');
|
||
let editStart = $state('');
|
||
let editEnd = $state('');
|
||
|
||
// History collapsed by default
|
||
let showHistory = $state(false);
|
||
|
||
// Sharing state
|
||
// svelte-ignore state_referenced_locally
|
||
let shareList = $state([...initialSharedWith]);
|
||
let showShare = $state(false);
|
||
let shareInput = $state('');
|
||
let shareSaving = $state(false);
|
||
|
||
// Calendar navigation: month offset from today
|
||
let calendarOffset = $state(0);
|
||
|
||
const today = new Date();
|
||
|
||
/** @param {Date} d */
|
||
function fmtLocal(d) {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
const todayStr = fmtLocal(today);
|
||
|
||
/** Normalize any Date to local-midnight timestamp */
|
||
/** @param {Date} dt */
|
||
function midnight(dt) { return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()).getTime(); }
|
||
|
||
/** Parse a date string (ISO or YYYY-MM-DD) to local-midnight timestamp */
|
||
/** @param {string} s */
|
||
function parseLocal(s) {
|
||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||
if (m) return new Date(+m[1], +m[2] - 1, +m[3]).getTime();
|
||
return midnight(new Date(s));
|
||
}
|
||
|
||
const todayMidnight = midnight(today);
|
||
|
||
/** @param {string|Date} dateStr */
|
||
function formatDate(dateStr) {
|
||
const d = dateStr instanceof Date ? dateStr : new Date(dateStr);
|
||
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', {
|
||
month: 'short', day: 'numeric'
|
||
});
|
||
}
|
||
|
||
/** Format a date as a relative description, e.g. "Tuesday in 3 weeks" */
|
||
/** @param {Date} date */
|
||
function relativeDate(date) {
|
||
const locale = lang === 'de' ? 'de-DE' : 'en-US';
|
||
const dayName = date.toLocaleDateString(locale, { weekday: 'long' });
|
||
const diffDays = Math.round((midnight(date) - todayMidnight) / 86400000);
|
||
|
||
if (diffDays === 0) return lang === 'de' ? 'Heute' : 'Today';
|
||
if (diffDays === 1) return lang === 'de' ? 'Morgen' : 'Tomorrow';
|
||
if (diffDays === -1) return lang === 'de' ? 'Gestern' : 'Yesterday';
|
||
|
||
if (diffDays > 0) {
|
||
if (diffDays < 7) {
|
||
return lang === 'de' ? `${dayName} (in ${diffDays} Tagen)` : `${dayName} (in ${diffDays} days)`;
|
||
}
|
||
const weeks = Math.ceil(diffDays / 7);
|
||
const wLabel = weeks === 1
|
||
? (lang === 'de' ? '1 Woche' : '1 week')
|
||
: (lang === 'de' ? `${weeks} Wochen` : `${weeks} weeks`);
|
||
return lang === 'de' ? `${dayName} in ${wLabel}` : `${dayName} in ${wLabel}`;
|
||
}
|
||
|
||
const absDays = Math.abs(diffDays);
|
||
if (absDays < 7) {
|
||
return lang === 'de' ? `${dayName} (vor ${absDays} Tagen)` : `${dayName} (${absDays} days ago)`;
|
||
}
|
||
const weeks = Math.ceil(absDays / 7);
|
||
const wLabel = weeks === 1
|
||
? (lang === 'de' ? '1 Woche' : '1 week')
|
||
: (lang === 'de' ? `${weeks} Wochen` : `${weeks} weeks`);
|
||
return lang === 'de' ? `${dayName} vor ${wLabel}` : `${dayName} ${wLabel} ago`;
|
||
}
|
||
|
||
/** Short relative range: "Tuesday to Saturday in 3 weeks" */
|
||
/** @param {Date} start @param {Date} end */
|
||
function relativeRange(start, end) {
|
||
const locale = lang === 'de' ? 'de-DE' : 'en-US';
|
||
const startDay = start.toLocaleDateString(locale, { weekday: 'long' });
|
||
const endDay = end.toLocaleDateString(locale, { weekday: 'long' });
|
||
const diffDays = Math.round((midnight(start) - todayMidnight) / 86400000);
|
||
const toWord = t.to;
|
||
|
||
if (diffDays >= 0 && diffDays < 7) {
|
||
return lang === 'de'
|
||
? `${startDay} ${toWord} ${endDay} (in ${diffDays} Tagen)`
|
||
: `${startDay} ${toWord} ${endDay} (in ${diffDays} days)`;
|
||
}
|
||
if (diffDays >= 7) {
|
||
const weeks = Math.ceil(diffDays / 7);
|
||
const wLabel = weeks === 1
|
||
? (lang === 'de' ? '1 Woche' : '1 week')
|
||
: (lang === 'de' ? `${weeks} Wochen` : `${weeks} weeks`);
|
||
return lang === 'de'
|
||
? `${startDay} ${toWord} ${endDay} in ${wLabel}`
|
||
: `${startDay} ${toWord} ${endDay} in ${wLabel}`;
|
||
}
|
||
return `${startDay} ${toWord} ${endDay}`;
|
||
}
|
||
|
||
// Sort periods newest first
|
||
const sorted = $derived([...periods].sort((a, b) =>
|
||
new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
||
));
|
||
|
||
// Ongoing period (no endDate)
|
||
const ongoing = $derived(sorted.find(p => !p.endDate));
|
||
|
||
// Completed periods (have endDate), oldest first for EMA
|
||
const completed = $derived(
|
||
sorted.filter(p => p.endDate).sort((a, b) =>
|
||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||
)
|
||
);
|
||
|
||
// EMA predictions (α = 0.3, population priors: 29-day cycle, 5-day period)
|
||
const predictions = $derived.by(() => {
|
||
const alpha = 0.3;
|
||
let emaCycle = 29;
|
||
let emaPeriod = 5;
|
||
|
||
/** @type {number[]} */
|
||
const cycleLengths = [];
|
||
/** @type {number[]} */
|
||
const periodLengths = [];
|
||
|
||
for (let i = 0; i < completed.length; i++) {
|
||
const p = completed[i];
|
||
const start = new Date(p.startDate);
|
||
const end = new Date(p.endDate);
|
||
const dur = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||
emaPeriod = alpha * dur + (1 - alpha) * emaPeriod;
|
||
periodLengths.push(dur);
|
||
|
||
if (i > 0) {
|
||
const prevStart = new Date(completed[i - 1].startDate);
|
||
const cycle = Math.round((start.getTime() - prevStart.getTime()) / 86400000);
|
||
if (cycle > 0 && cycle < 60) {
|
||
emaCycle = alpha * cycle + (1 - alpha) * emaCycle;
|
||
cycleLengths.push(cycle);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 95% CI half-width: 1.96 * s / √n */
|
||
/** @param {number[]} arr */
|
||
function ci95(arr) {
|
||
if (arr.length < 2) return 0;
|
||
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
|
||
const variance = arr.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (arr.length - 1);
|
||
return Math.round(1.96 * Math.sqrt(variance / arr.length) * 10) / 10;
|
||
}
|
||
|
||
const cycleVariance = ci95(cycleLengths);
|
||
const periodVariance = ci95(periodLengths);
|
||
|
||
// Predict ongoing end
|
||
let predictedEndOfOngoing = null;
|
||
if (ongoing) {
|
||
const ongoingStart = new Date(ongoing.startDate);
|
||
predictedEndOfOngoing = new Date(ongoingStart.getTime() + (Math.round(emaPeriod) - 1) * 86400000);
|
||
}
|
||
|
||
// Generate future predicted cycles (12 cycles ≈ ~1 year)
|
||
const meanCycleDays = Math.round(emaCycle);
|
||
const cycleMs = meanCycleDays * 86400000;
|
||
const periodMs = (Math.round(emaPeriod) - 1) * 86400000;
|
||
const lutealLength = 14;
|
||
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null;
|
||
|
||
// Cycle range for Ogino-style widening of future fertile windows.
|
||
// Without ≥2 observed cycles, widening collapses to a point estimate.
|
||
const shortestCycle = cycleLengths.length >= 2 ? Math.min(...cycleLengths) : meanCycleDays;
|
||
const longestCycle = cycleLengths.length >= 2 ? Math.max(...cycleLengths) : meanCycleDays;
|
||
|
||
/**
|
||
* Build a fertility window for one cycle.
|
||
*
|
||
* Anchor: the next period's start (luteal-back-count). Past cycles know it
|
||
* exactly; future cycles use the mean prediction and widen the outer fertile
|
||
* range to cover the earliest/latest historically observed ovulation day.
|
||
*
|
||
* Floor: fertile/peak never overlap the prior bleed. Day-after-period-end
|
||
* is the earliest possible fertile day shown — a hard biological floor for
|
||
* the user's mental model, even though sperm survival could in theory begin
|
||
* earlier in the bleed for very short cycles.
|
||
*
|
||
* @param {number} cycleStartMs ms of cycle start (= prior period start)
|
||
* @param {number | null} priorPeriodEndMs ms of prior bleed end, or null if unknown
|
||
* @param {number} nextPeriodStartMs ms of the next period's start
|
||
* @param {boolean} widen true → use shortest/longest cycle bounds; false → point estimate
|
||
*/
|
||
function buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, widen) {
|
||
const ovMs = nextPeriodStartMs - lutealLength * 86400000;
|
||
const earliestOvMs = widen
|
||
? cycleStartMs + (shortestCycle - lutealLength) * 86400000
|
||
: ovMs;
|
||
let latestOvMs = widen
|
||
? cycleStartMs + (longestCycle - lutealLength) * 86400000
|
||
: ovMs;
|
||
// Cap latest ov before the next bleed starts.
|
||
if (latestOvMs > nextPeriodStartMs - 86400000) latestOvMs = nextPeriodStartMs - 86400000;
|
||
|
||
const floorMs = priorPeriodEndMs !== null ? priorPeriodEndMs + 86400000 : cycleStartMs;
|
||
|
||
let fertileStartMs = Math.max(earliestOvMs - 5 * 86400000, floorMs, cycleStartMs);
|
||
let peakStartMs = Math.max(ovMs - 2 * 86400000, floorMs, cycleStartMs);
|
||
const peakEndMs = ovMs - 86400000;
|
||
let fertileEndMs = Math.max(latestOvMs, ovMs);
|
||
|
||
// Suppress peak if floor pushed it past ov (e.g. very short cycle + long period).
|
||
if (peakStartMs > peakEndMs) peakStartMs = peakEndMs + 86400000;
|
||
// Keep fertile envelope around peak/ov.
|
||
if (fertileStartMs > peakStartMs && peakStartMs <= peakEndMs) fertileStartMs = peakStartMs;
|
||
|
||
return {
|
||
fertileStart: new Date(fertileStartMs),
|
||
fertileEnd: new Date(fertileEndMs),
|
||
peakStart: new Date(peakStartMs),
|
||
peakEnd: new Date(peakEndMs),
|
||
ovulation: new Date(ovMs),
|
||
lutealStart: new Date(latestOvMs + 86400000),
|
||
lutealEnd: new Date(nextPeriodStartMs - 86400000)
|
||
};
|
||
}
|
||
|
||
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
||
const futureCycles = [];
|
||
if (lastStart) {
|
||
let base = lastStart.getTime();
|
||
// Prior bleed end for the first predicted cycle: actual end if recorded,
|
||
// predicted end if ongoing, else cycle start.
|
||
let priorPeriodEndMs;
|
||
if (sorted[0]?.endDate) {
|
||
priorPeriodEndMs = midnight(new Date(sorted[0].endDate));
|
||
} else if (predictedEndOfOngoing) {
|
||
priorPeriodEndMs = midnight(predictedEndOfOngoing);
|
||
} else {
|
||
priorPeriodEndMs = base;
|
||
}
|
||
|
||
for (let i = 0; i < 12; i++) {
|
||
const nextPeriodStartMs = base + cycleMs;
|
||
const periodEndMs = nextPeriodStartMs + periodMs;
|
||
const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
|
||
futureCycles.push({
|
||
start: new Date(nextPeriodStartMs),
|
||
end: new Date(periodEndMs),
|
||
...w
|
||
});
|
||
base = nextPeriodStartMs;
|
||
priorPeriodEndMs = periodEndMs;
|
||
}
|
||
}
|
||
|
||
// Past fertility/luteal windows (from completed cycles)
|
||
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
|
||
const pastFertileWindows = [];
|
||
for (let i = 1; i < completed.length; i++) {
|
||
const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
|
||
const priorPeriodEndMs = completed[i - 1].endDate
|
||
? midnight(new Date(completed[i - 1].endDate))
|
||
: null;
|
||
const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
|
||
pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
|
||
}
|
||
|
||
return {
|
||
avgCycle: Math.round(emaCycle * 10) / 10,
|
||
avgPeriod: Math.round(emaPeriod * 10) / 10,
|
||
cycleVariance,
|
||
periodVariance,
|
||
predictedEndOfOngoing,
|
||
futureCycles,
|
||
pastFertileWindows
|
||
};
|
||
});
|
||
|
||
// First future cycle (for status display)
|
||
const nextCycle = $derived(predictions.futureCycles[0] ?? null);
|
||
|
||
// Days into current period
|
||
const ongoingDay = $derived.by(() => {
|
||
if (!ongoing) return 0;
|
||
const start = parseLocal(ongoing.startDate);
|
||
return Math.floor((todayMidnight - start) / 86400000) + 1;
|
||
});
|
||
|
||
// Calendar data
|
||
const calendarMonth = $derived.by(() => {
|
||
const d = new Date(today.getFullYear(), today.getMonth() + calendarOffset, 1);
|
||
return { year: d.getFullYear(), month: d.getMonth() };
|
||
});
|
||
|
||
const calendarLabel = $derived(
|
||
new Date(calendarMonth.year, calendarMonth.month).toLocaleDateString(
|
||
lang === 'de' ? 'de-DE' : 'en-US', { month: 'long', year: 'numeric' }
|
||
)
|
||
);
|
||
|
||
const calendarDays = $derived.by(() => {
|
||
const { year, month } = calendarMonth;
|
||
const first = new Date(year, month, 1);
|
||
const last = new Date(year, month + 1, 0);
|
||
const startDay = (first.getDay() + 6) % 7; // Monday = 0
|
||
|
||
// Build raw cells with status, including overflow days from adjacent months
|
||
/** @type {{ day: number, date: string, status: string, pos: string, edges: string, overflow?: boolean }[]} */
|
||
const cells = [];
|
||
|
||
// Previous month overflow
|
||
if (startDay > 0) {
|
||
const prevLast = new Date(year, month, 0); // last day of previous month
|
||
for (let i = startDay - 1; i >= 0; i--) {
|
||
const d = prevLast.getDate() - i;
|
||
const pm = new Date(year, month - 1, d);
|
||
const date = fmtLocal(pm);
|
||
cells.push({ day: d, date, status: getDayStatus(date), pos: '', edges: '', overflow: true });
|
||
}
|
||
}
|
||
|
||
// Current month
|
||
for (let d = 1; d <= last.getDate(); d++) {
|
||
const date = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||
cells.push({ day: d, date, status: getDayStatus(date), pos: '', edges: '' });
|
||
}
|
||
|
||
// Next month overflow to complete the last row
|
||
const remainder = cells.length % 7;
|
||
if (remainder > 0) {
|
||
const fill = 7 - remainder;
|
||
for (let d = 1; d <= fill; d++) {
|
||
const nm = new Date(year, month + 1, d);
|
||
const date = fmtLocal(nm);
|
||
cells.push({ day: d, date, status: getDayStatus(date), pos: '', edges: '', overflow: true });
|
||
}
|
||
}
|
||
|
||
// Status grouping: these flow into each other without rounding at boundaries
|
||
/** @param {string} s */
|
||
function statusGroup(s) {
|
||
if (s === 'fertile' || s === 'peak-fertile' || s === 'ovulation') return 'fertility';
|
||
return s;
|
||
}
|
||
|
||
// Sub-grouping: peak-fertile and ovulation form one pill
|
||
/** @param {string} s */
|
||
function posGroup(s) {
|
||
if (s === 'peak-fertile' || s === 'ovulation') return 'peak';
|
||
return s;
|
||
}
|
||
|
||
// Compute range positions and edge flags
|
||
// Position (border-radius): posGroup for left (peak-fertile+ovulation = one pill,
|
||
// but fertile is separate so peak-fertile gets rounded left cap next to fertile)
|
||
// statusGroup for right (fertile stays flat on right next to peak-fertile)
|
||
// Edges (border sides): posGroup for left, statusGroup for right/up/down
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const c = cells[i];
|
||
if (!c || !c.status) continue;
|
||
const g = statusGroup(c.status);
|
||
const pg = posGroup(c.status);
|
||
const col = i % 7;
|
||
const leftSamePos = col > 0 && posGroup(cells[i - 1]?.status ?? '') === pg;
|
||
const rightGroup = col < 6 && i + 1 < cells.length && statusGroup(cells[i + 1]?.status ?? '') === g;
|
||
const upGroup = i >= 7 && statusGroup(cells[i - 7]?.status ?? '') === g;
|
||
const downGroup = i + 7 < cells.length && statusGroup(cells[i + 7]?.status ?? '') === g;
|
||
|
||
// Horizontal position for left/right rounding
|
||
if (!leftSamePos && !rightGroup) c.pos = 'solo';
|
||
else if (!leftSamePos) c.pos = 'start';
|
||
else if (!rightGroup) c.pos = 'end';
|
||
else c.pos = 'mid';
|
||
|
||
// If range continues across row boundary (col 6 → col 0), flatten the corners
|
||
// Right/next: statusGroup (fertile stays flat before peak-fertile)
|
||
// Left/prev: posGroup (peak-fertile keeps rounded cap after fertile)
|
||
const nextDaySameGroup = col === 6 && i + 1 < cells.length && statusGroup(cells[i + 1]?.status ?? '') === g;
|
||
const prevDaySamePos = col === 0 && i - 1 >= 0 && posGroup(cells[i - 1]?.status ?? '') === pg;
|
||
if (nextDaySameGroup) {
|
||
if (c.pos === 'solo') c.pos = 'start';
|
||
else if (c.pos === 'end') c.pos = 'mid';
|
||
}
|
||
if (prevDaySamePos) {
|
||
if (c.pos === 'start') c.pos = 'mid';
|
||
else if (c.pos === 'solo') c.pos = 'end';
|
||
}
|
||
|
||
// Edge flags for border rendering
|
||
// Left: posGroup match (peak-fertile draws left border next to fertile)
|
||
// Right/Up/Down: group match (borders flow within group)
|
||
const edges = [];
|
||
if (!upGroup) edges.push('et');
|
||
if (!downGroup) edges.push('eb');
|
||
// At col 0, also check cross-row prev day with posGroup
|
||
const leftSameEdge = leftSamePos || prevDaySamePos;
|
||
if (!leftSameEdge) edges.push('el');
|
||
// At col 6, also check cross-row next day with statusGroup
|
||
const rightSameEdge = rightGroup || nextDaySameGroup;
|
||
if (!rightSameEdge) edges.push('er');
|
||
c.edges = edges.join(' ');
|
||
}
|
||
|
||
return cells;
|
||
});
|
||
|
||
/** @param {string} dateStr */
|
||
function getDayStatus(dateStr) {
|
||
const d = parseLocal(dateStr);
|
||
|
||
// Period days (actual)
|
||
for (const p of periods) {
|
||
const start = midnight(new Date(p.startDate));
|
||
const end = p.endDate ? midnight(new Date(p.endDate)) : todayMidnight;
|
||
if (d >= start && d <= end) return 'period';
|
||
}
|
||
|
||
// Predicted ongoing end
|
||
if (ongoing && predictions.predictedEndOfOngoing) {
|
||
const ongoingEnd = midnight(predictions.predictedEndOfOngoing);
|
||
const ongoingStart = midnight(new Date(ongoing.startDate));
|
||
if (d > todayMidnight && d >= ongoingStart && d <= ongoingEnd) return 'predicted';
|
||
}
|
||
|
||
// Future predicted cycles
|
||
for (const c of predictions.futureCycles) {
|
||
const cs = midnight(c.start);
|
||
const ce = midnight(c.end);
|
||
if (d >= cs && d <= ce) return 'predicted';
|
||
const ovDay = midnight(c.ovulation);
|
||
if (d === ovDay) return 'ovulation';
|
||
const ps = midnight(c.peakStart);
|
||
const pe = midnight(c.peakEnd);
|
||
if (d >= ps && d <= pe) return 'peak-fertile';
|
||
const fs = midnight(c.fertileStart);
|
||
const fe = midnight(c.fertileEnd);
|
||
if (d >= fs && d <= fe) return 'fertile';
|
||
const ls = midnight(c.lutealStart);
|
||
const le = midnight(c.lutealEnd);
|
||
if (d >= ls && d <= le) return 'luteal';
|
||
}
|
||
|
||
// Past fertility/luteal windows
|
||
for (const w of predictions.pastFertileWindows) {
|
||
const ovDay = midnight(w.ovulation);
|
||
if (d === ovDay) return 'ovulation';
|
||
const ps = midnight(w.peakStart);
|
||
const pe = midnight(w.peakEnd);
|
||
if (d >= ps && d <= pe) return 'peak-fertile';
|
||
const fs = midnight(w.fertileStart);
|
||
const fe = midnight(w.fertileEnd);
|
||
if (d >= fs && d <= fe) return 'fertile';
|
||
const ls = midnight(w.lutealStart);
|
||
const le = midnight(w.lutealEnd);
|
||
if (d >= ls && d <= le) return 'luteal';
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
async function startPeriod() {
|
||
loading = true;
|
||
try {
|
||
const res = await fetch('/api/fitness/period', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ startDate: new Date().toISOString() })
|
||
});
|
||
if (res.ok) {
|
||
const { entry } = await res.json();
|
||
periods = [entry, ...periods];
|
||
} else {
|
||
const err = await res.json().catch(() => null);
|
||
toast.error(err?.error ?? 'Failed to start period');
|
||
}
|
||
} catch { toast.error('Failed to start period'); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
/** @param {string} dateStr — YYYY-MM-DD from a calendar cell */
|
||
async function promptStartPeriodOn(dateStr) {
|
||
const d = new Date(parseLocal(dateStr));
|
||
const ok = await confirm(
|
||
lang === 'de'
|
||
? `Periode am ${formatDate(d)} starten?`
|
||
: `Start period on ${formatDate(d)}?`,
|
||
{
|
||
title: lang === 'de' ? 'Periode starten' : 'Start period',
|
||
confirmText: lang === 'de' ? 'Starten' : 'Start',
|
||
cancelText: lang === 'de' ? 'Abbrechen' : 'Cancel',
|
||
destructive: false
|
||
}
|
||
);
|
||
if (!ok) return;
|
||
loading = true;
|
||
try {
|
||
const res = await fetch('/api/fitness/period', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ startDate: d.toISOString() })
|
||
});
|
||
if (res.ok) {
|
||
const { entry } = await res.json();
|
||
periods = [entry, ...periods];
|
||
} else {
|
||
const err = await res.json().catch(() => null);
|
||
toast.error(err?.error ?? 'Failed to start period');
|
||
}
|
||
} catch { toast.error('Failed to start period'); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
/**
|
||
* Long-press attachment. Fires `handler` after THRESHOLD ms of unmoving
|
||
* pointer contact. Cancels on movement > MOVE_TOL, pointer leave/cancel,
|
||
* or release before threshold. Suppresses the browser context menu when
|
||
* the gesture fires (iOS otherwise pops a callout on touch hold).
|
||
*
|
||
* @param {() => void} handler
|
||
* @returns {import('svelte/attachments').Attachment<HTMLElement>}
|
||
*/
|
||
function longPress(handler) {
|
||
const THRESHOLD = 600;
|
||
const MOVE_TOL = 8;
|
||
return (node) => {
|
||
/** @type {number | null} */
|
||
let timer = null;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let firing = false;
|
||
|
||
function clear() {
|
||
if (timer !== null) {
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
}
|
||
node.classList.remove('long-pressing');
|
||
}
|
||
|
||
/** @param {PointerEvent} e */
|
||
function onPointerDown(e) {
|
||
if (e.button !== undefined && e.button !== 0) return;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
firing = false;
|
||
node.classList.add('long-pressing');
|
||
timer = window.setTimeout(() => {
|
||
firing = true;
|
||
node.classList.remove('long-pressing');
|
||
timer = null;
|
||
handler();
|
||
}, THRESHOLD);
|
||
}
|
||
|
||
/** @param {PointerEvent} e */
|
||
function onPointerMove(e) {
|
||
if (timer === null) return;
|
||
if (Math.abs(e.clientX - startX) > MOVE_TOL || Math.abs(e.clientY - startY) > MOVE_TOL) {
|
||
clear();
|
||
}
|
||
}
|
||
|
||
/** @param {Event} e */
|
||
function onContextMenu(e) {
|
||
if (firing) {
|
||
e.preventDefault();
|
||
firing = false;
|
||
}
|
||
}
|
||
|
||
node.addEventListener('pointerdown', onPointerDown);
|
||
node.addEventListener('pointermove', onPointerMove);
|
||
node.addEventListener('pointerup', clear);
|
||
node.addEventListener('pointerleave', clear);
|
||
node.addEventListener('pointercancel', clear);
|
||
node.addEventListener('contextmenu', onContextMenu);
|
||
|
||
return () => {
|
||
clear();
|
||
node.removeEventListener('pointerdown', onPointerDown);
|
||
node.removeEventListener('pointermove', onPointerMove);
|
||
node.removeEventListener('pointerup', clear);
|
||
node.removeEventListener('pointerleave', clear);
|
||
node.removeEventListener('pointercancel', clear);
|
||
node.removeEventListener('contextmenu', onContextMenu);
|
||
};
|
||
};
|
||
}
|
||
|
||
/** Whether long-pressing the given calendar cell can start a period. */
|
||
/** @param {{ date: string, status: string }} cell */
|
||
function canStartOn(cell) {
|
||
if (readOnly || !showEntry) return false;
|
||
if (ongoing) return false;
|
||
if (cell.status === 'period') return false;
|
||
return parseLocal(cell.date) <= todayMidnight;
|
||
}
|
||
|
||
async function endPeriod() {
|
||
if (!ongoing) return;
|
||
loading = true;
|
||
try {
|
||
const res = await fetch(`/api/fitness/period/${ongoing._id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ endDate: new Date(Date.now() - 86400000).toISOString() })
|
||
});
|
||
if (res.ok) {
|
||
const { entry } = await res.json();
|
||
periods = periods.map(p => p._id === entry._id ? entry : p);
|
||
} else {
|
||
const err = await res.json().catch(() => null);
|
||
toast.error(err?.error ?? 'Failed to end period');
|
||
}
|
||
} catch { toast.error('Failed to end period'); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
async function addPastPeriod() {
|
||
if (!addStart) return;
|
||
loading = true;
|
||
try {
|
||
const body = { startDate: new Date(addStart).toISOString() };
|
||
if (addEnd) Object.assign(body, { endDate: new Date(addEnd).toISOString() });
|
||
const res = await fetch('/api/fitness/period', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (res.ok) {
|
||
const { entry } = await res.json();
|
||
periods = [entry, ...periods];
|
||
showAddForm = false;
|
||
addStart = '';
|
||
addEnd = '';
|
||
} else {
|
||
const err = await res.json().catch(() => null);
|
||
toast.error(err?.error ?? 'Failed to add period');
|
||
}
|
||
} catch { toast.error('Failed to add period'); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
/** @param {any} p */
|
||
function startEdit(p) {
|
||
editId = p._id;
|
||
editStart = fmtLocal(new Date(p.startDate));
|
||
editEnd = p.endDate ? fmtLocal(new Date(p.endDate)) : '';
|
||
}
|
||
|
||
function cancelEdit() {
|
||
editId = '';
|
||
editStart = '';
|
||
editEnd = '';
|
||
}
|
||
|
||
async function saveEdit() {
|
||
if (!editStart) return;
|
||
loading = true;
|
||
try {
|
||
/** @type {Record<string, unknown>} */
|
||
const body = { startDate: new Date(editStart).toISOString() };
|
||
body.endDate = editEnd ? new Date(editEnd).toISOString() : null;
|
||
const res = await fetch(`/api/fitness/period/${editId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (res.ok) {
|
||
const { entry } = await res.json();
|
||
periods = periods.map(p => p._id === editId ? entry : p);
|
||
cancelEdit();
|
||
} else {
|
||
const err = await res.json().catch(() => null);
|
||
toast.error(err?.error ?? 'Failed to update period');
|
||
}
|
||
} catch { toast.error('Failed to update period'); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
/** @param {string} id */
|
||
async function deletePeriod(id) {
|
||
if (!await confirm(t.delete_period_confirm)) return;
|
||
try {
|
||
const res = await fetch(`/api/fitness/period/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
periods = periods.filter(p => p._id !== id);
|
||
} else {
|
||
toast.error('Failed to delete period');
|
||
}
|
||
} catch { toast.error('Failed to delete period'); }
|
||
}
|
||
|
||
async function updateShareList(/** @type {string[]} */ newList) {
|
||
shareSaving = true;
|
||
try {
|
||
const res = await fetch('/api/fitness/period/share', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sharedWith: newList })
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
shareList = data.sharedWith;
|
||
} else {
|
||
toast.error('Failed to update sharing');
|
||
}
|
||
} catch { toast.error('Failed to update sharing'); }
|
||
finally { shareSaving = false; }
|
||
}
|
||
|
||
function addShareUser() {
|
||
const name = shareInput.trim().toLowerCase();
|
||
if (!name || shareList.includes(name)) return;
|
||
shareInput = '';
|
||
updateShareList([...shareList, name]);
|
||
}
|
||
|
||
/** @param {string} name */
|
||
function removeShareUser(name) {
|
||
updateShareList(shareList.filter(u => u !== name));
|
||
}
|
||
|
||
const weekDays = $derived(
|
||
lang === 'de'
|
||
? ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||
: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||
);
|
||
</script>
|
||
|
||
<section class="period-tracker" class:read-only={readOnly}>
|
||
<h2>
|
||
{#if readOnly && ownerName}
|
||
<span class="shared-header">
|
||
<ProfilePicture username={ownerName} size={24} />
|
||
{ownerName}
|
||
</span>
|
||
{:else}
|
||
{t.period_tracker}
|
||
{/if}
|
||
</h2>
|
||
|
||
<!-- Status card -->
|
||
<div class="status-card">
|
||
{#if ongoing}
|
||
<div class="status-split">
|
||
<div class="status-main">
|
||
<span class="status-pill period-pill">{t.current_period}</span>
|
||
<span class="status-hero ongoing-hero">{t.period_day} {ongoingDay}</span>
|
||
{#if showProjection && predictions.predictedEndOfOngoing}
|
||
<span class="status-detail">{t.predicted_end}</span>
|
||
<span class="status-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span>
|
||
<span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span>
|
||
{/if}
|
||
{#if showEntry && !readOnly}
|
||
<button class="end-btn" onclick={endPeriod} disabled={loading}>
|
||
<span class="end-btn-icon"><Check size={18} strokeWidth={2.5} /></span>
|
||
<span class="end-btn-label">{t.end_period}</span>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#if showProjection && nextCycle}
|
||
<div class="status-side">
|
||
<div class="status-side-item ovulation-accent">
|
||
<span class="status-side-label">{t.ovulation}</span>
|
||
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||
</div>
|
||
<div class="status-side-item fertile-accent">
|
||
<span class="status-side-label">{t.fertile}</span>
|
||
<span class="status-side-date">{formatDate(nextCycle.fertileStart)} — {formatDate(nextCycle.fertileEnd)}</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if showProjection && nextCycle}
|
||
<div class="status-split">
|
||
<div class="status-main">
|
||
<span class="status-pill period-pill">{t.next_period}</span>
|
||
<span class="status-hero">{relativeRange(nextCycle.start, nextCycle.end)}</span>
|
||
<span class="status-date">{formatDate(nextCycle.start)} — {formatDate(nextCycle.end)}</span>
|
||
{#if showEntry && !readOnly}
|
||
<button class="start-btn" onclick={startPeriod} disabled={loading}>
|
||
{t.start_period}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
<div class="status-side">
|
||
<div class="status-side-item ovulation-accent">
|
||
<span class="status-side-label">{t.ovulation}</span>
|
||
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
|
||
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
|
||
</div>
|
||
<div class="status-side-item fertile-accent">
|
||
<span class="status-side-label">{t.fertile}</span>
|
||
<span class="status-side-date">{formatDate(nextCycle.fertileStart)} — {formatDate(nextCycle.fertileEnd)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else if showEntry}
|
||
<div class="status-block">
|
||
<span class="status-empty">{sorted.length === 0 ? t.no_period_data : t.no_active_period}</span>
|
||
{#if !readOnly}
|
||
<button class="start-btn" onclick={startPeriod} disabled={loading}>
|
||
{t.start_period}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if showProjection}
|
||
<!-- Calendar -->
|
||
<div class="calendar">
|
||
<div class="cal-header">
|
||
<button class="cal-nav" onclick={() => calendarOffset--}>
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<span class="cal-title">{calendarLabel}</span>
|
||
<button class="cal-nav" onclick={() => calendarOffset++} disabled={calendarOffset >= 12}>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
{#if calendarOffset !== 0}
|
||
<button class="go-today-btn" onclick={() => calendarOffset = 0}>{lang === 'de' ? 'Heute' : 'Today'}</button>
|
||
{/if}
|
||
</div>
|
||
<div class="cal-weekdays">
|
||
{#each weekDays as wd}
|
||
<span class="cal-wd">{wd}</span>
|
||
{/each}
|
||
</div>
|
||
<div class="cal-grid">
|
||
{#each calendarDays as cell}
|
||
{@const startable = canStartOn(cell)}
|
||
<span
|
||
class="cal-day {cell.status ? `s-${cell.status}` : ''} {cell.pos ? `p-${cell.pos}` : ''} {cell.edges}"
|
||
class:today={cell.date === todayStr}
|
||
class:overflow={cell.overflow}
|
||
class:startable
|
||
{@attach startable && longPress(() => promptStartPeriodOn(cell.date))}
|
||
>{cell.day}</span>
|
||
{/each}
|
||
</div>
|
||
<div class="cal-legend">
|
||
<span class="legend-item"><span class="legend-dot period"></span> {lang === 'de' ? 'Periode' : 'Period'}</span>
|
||
<span class="legend-item"><span class="legend-dot predicted"></span> {lang === 'de' ? 'Prognose' : 'Predicted'}</span>
|
||
<span class="legend-item"><span class="legend-dot fertile"></span> {t.fertile}</span>
|
||
<span class="legend-item"><span class="legend-dot peak-fertile"></span> {t.peak_fertility}</span>
|
||
<span class="legend-item"><span class="legend-dot ovulation"></span> {t.ovulation}</span>
|
||
<span class="legend-item"><span class="legend-dot luteal"></span> {t.luteal_phase}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/if}
|
||
|
||
{#if showProjection && completed.length >= 2}
|
||
<div class="cycle-stats">
|
||
<div class="cycle-stat">
|
||
<span class="cycle-stat-label">{t.cycle_length}</span>
|
||
<span class="cycle-stat-value">{Math.round(predictions.avgCycle)} {t.days}</span>
|
||
{#if predictions.cycleVariance > 0}
|
||
<span class="cycle-stat-variance">± {predictions.cycleVariance} {t.days} (95% CI)</span>
|
||
{/if}
|
||
</div>
|
||
<div class="cycle-stat">
|
||
<span class="cycle-stat-label">{t.period_length}</span>
|
||
<span class="cycle-stat-value">{Math.round(predictions.avgPeriod)} {t.days}</span>
|
||
{#if predictions.periodVariance > 0}
|
||
<span class="cycle-stat-variance">± {predictions.periodVariance} {t.days} (95% CI)</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showEntry && !readOnly}
|
||
<!-- History + Share row -->
|
||
{#if sorted.length > 0}
|
||
<div class="history">
|
||
<div class="history-share-row">
|
||
<button class="history-toggle" onclick={() => showHistory = !showHistory}>
|
||
<h3>{t.history}</h3>
|
||
<ChevronRight size={14} class={showHistory ? 'chevron open' : 'chevron'} />
|
||
</button>
|
||
<div class="share-bar">
|
||
{#if shareList.length > 0}
|
||
<div class="shared-avatars">
|
||
<span class="shared-label">{t.shared_with}</span>
|
||
{#each shareList as user}
|
||
<div class="shared-avatar" title={user}>
|
||
<ProfilePicture username={user} size={28} />
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
|
||
<UserPlus size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if showHistory}
|
||
<div class="history-header">
|
||
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
|
||
<Plus size={14} />
|
||
{t.add_past_period}
|
||
</button>
|
||
</div>
|
||
|
||
{#if showAddForm}
|
||
<div class="add-form">
|
||
<div class="add-row">
|
||
<label>
|
||
{t.period_start}
|
||
<DatePicker bind:value={addStart} max={todayStr} {lang} />
|
||
</label>
|
||
<label>
|
||
{t.period_end}
|
||
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
|
||
</label>
|
||
</div>
|
||
<div class="add-actions">
|
||
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
|
||
{t.save}
|
||
</button>
|
||
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
|
||
{t.cancel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="history-list">
|
||
{#each sorted as p (p._id)}
|
||
{#if editId === p._id}
|
||
<div class="history-item editing">
|
||
<div class="add-row">
|
||
<label>
|
||
{t.period_start}
|
||
<DatePicker bind:value={editStart} {lang} />
|
||
</label>
|
||
<label>
|
||
{t.period_end}
|
||
<DatePicker bind:value={editEnd} min={editStart} {lang} />
|
||
</label>
|
||
</div>
|
||
<div class="add-actions">
|
||
<button class="save-btn" onclick={saveEdit} disabled={!editStart || loading}>
|
||
{t.save}
|
||
</button>
|
||
<button class="cancel-btn" onclick={cancelEdit}>
|
||
{t.cancel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="history-item">
|
||
<div class="history-info">
|
||
<span class="history-dates">
|
||
{formatDate(p.startDate)}
|
||
{#if p.endDate}
|
||
— {formatDate(p.endDate)}
|
||
{:else}
|
||
<span class="ongoing-badge">{t.ongoing}</span>
|
||
{/if}
|
||
</span>
|
||
{#if p.endDate}
|
||
{@const dur = Math.round((new Date(p.endDate).getTime() - new Date(p.startDate).getTime()) / 86400000) + 1}
|
||
<span class="history-dur">{dur} {t.days}</span>
|
||
{/if}
|
||
</div>
|
||
<div class="history-actions">
|
||
<button class="icon-btn edit" onclick={() => startEdit(p)} aria-label="Edit">
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button class="icon-btn delete" onclick={() => deletePeriod(p._id)} aria-label="Delete">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="empty-state">
|
||
<div class="share-bar">
|
||
<p>{t.no_period_data}</p>
|
||
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
|
||
<UserPlus size={16} />
|
||
</button>
|
||
</div>
|
||
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
|
||
<Plus size={14} />
|
||
{t.add_past_period}
|
||
</button>
|
||
{#if showAddForm}
|
||
<div class="add-form">
|
||
<div class="add-row">
|
||
<label>
|
||
{t.period_start}
|
||
<DatePicker bind:value={addStart} max={todayStr} {lang} />
|
||
</label>
|
||
<label>
|
||
{t.period_end}
|
||
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
|
||
</label>
|
||
</div>
|
||
<div class="add-actions">
|
||
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
|
||
{t.save}
|
||
</button>
|
||
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
|
||
{t.cancel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
{/if}
|
||
</section>
|
||
|
||
<!-- Share modal -->
|
||
{#if showShare}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||
<div class="share-overlay" onclick={() => showShare = false} onkeydown={(e) => e.key === 'Escape' && (showShare = false)}>
|
||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||
<div class="share-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||
<div class="share-modal-header">
|
||
<h3>{t.share}</h3>
|
||
<button class="share-modal-close" onclick={() => showShare = false}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
{#if shareList.length > 0}
|
||
<div class="share-user-list">
|
||
{#each shareList as user}
|
||
<div class="share-user">
|
||
<ProfilePicture username={user} size={32} />
|
||
<span class="share-username">{user}</span>
|
||
<button class="chip-remove" onclick={() => removeShareUser(user)} disabled={shareSaving} aria-label="Remove {user}">
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<span class="share-empty">{t.no_shared}</span>
|
||
{/if}
|
||
<form class="share-add" onsubmit={(e) => { e.preventDefault(); addShareUser(); }}>
|
||
<input
|
||
type="text"
|
||
bind:value={shareInput}
|
||
placeholder={t.add_user}
|
||
disabled={shareSaving}
|
||
/>
|
||
<button type="submit" class="share-add-btn" disabled={!shareInput.trim() || shareSaving}>
|
||
<Plus size={14} />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.period-tracker {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.period-tracker h2 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
/* Status card — split columns */
|
||
.status-card {
|
||
background: var(--color-surface);
|
||
border-radius: 10px;
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 1rem 1.1rem;
|
||
}
|
||
.status-split {
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
.status-main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
min-width: 0;
|
||
}
|
||
.status-side {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding-left: 1rem;
|
||
border-left: 1px solid var(--color-border);
|
||
justify-content: center;
|
||
}
|
||
.status-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
/* Side items with colored left accent */
|
||
.status-side-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
padding-left: 0.6rem;
|
||
border-left: 3px solid transparent;
|
||
}
|
||
.status-side-item.ovulation-accent { border-left-color: var(--blue); }
|
||
.status-side-item.fertile-accent { border-left-color: color-mix(in srgb, var(--blue) 40%, transparent); }
|
||
|
||
.status-side-label {
|
||
font-size: 0.6rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.status-side-relative {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
white-space: nowrap;
|
||
}
|
||
.status-side-date {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Main column labels */
|
||
.status-pill {
|
||
display: inline-block;
|
||
align-self: flex-start;
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: 10px;
|
||
}
|
||
.status-pill.period-pill {
|
||
background: var(--nord11);
|
||
color: white;
|
||
}
|
||
.status-label {
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.status-hero {
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
color: var(--color-text-primary);
|
||
line-height: 1.25;
|
||
}
|
||
.status-hero.ongoing-hero {
|
||
font-size: 1.5rem;
|
||
color: var(--nord11);
|
||
}
|
||
.status-relative {
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.status-detail {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
font-weight: 500;
|
||
margin-top: 0.2rem;
|
||
}
|
||
.status-date {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.status-empty {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.start-btn {
|
||
padding: 0.45rem 0.9rem;
|
||
border: none;
|
||
border-radius: 7px;
|
||
font-weight: 600;
|
||
font-size: 0.75rem;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
align-self: flex-start;
|
||
margin-top: 0.6rem;
|
||
background: var(--nord11);
|
||
color: white;
|
||
}
|
||
.start-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Prominent end-period CTA — flat fill, full width */
|
||
.end-btn {
|
||
align-self: stretch;
|
||
margin-top: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.55rem;
|
||
padding: 0.8rem 1.1rem;
|
||
border: none;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
color: white;
|
||
background: var(--nord11);
|
||
box-shadow: var(--shadow-sm);
|
||
transition: background 140ms ease;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.end-btn:hover {
|
||
background: color-mix(in srgb, var(--nord11) 88%, black);
|
||
}
|
||
.end-btn:active {
|
||
background: color-mix(in srgb, var(--nord11) 80%, black);
|
||
}
|
||
.end-btn:focus-visible {
|
||
outline: 2px solid var(--nord11);
|
||
outline-offset: 2px;
|
||
}
|
||
.end-btn:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.end-btn-label {
|
||
font-weight: 700;
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.end-btn-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
@media (max-width: 360px) {
|
||
.status-split { flex-direction: column; gap: 0.6rem; }
|
||
.status-side { border-left: none; padding-left: 0; border-top: 1px solid var(--color-border); padding-top: 0.6rem; flex-direction: row; gap: 1rem; }
|
||
}
|
||
|
||
/* Stats row */
|
||
/* Calendar */
|
||
.calendar {
|
||
background: var(--color-surface);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 0.75rem;
|
||
}
|
||
.cal-header {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
margin-bottom: 0.5rem;
|
||
position: relative;
|
||
}
|
||
.go-today-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.go-today-btn:hover {
|
||
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||
}
|
||
.cal-title {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
text-transform: capitalize;
|
||
}
|
||
.cal-nav {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
padding: 0.25rem;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
}
|
||
.cal-nav:hover { color: var(--color-text-primary); }
|
||
.cal-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||
|
||
.cal-weekdays {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
margin-bottom: 2px;
|
||
}
|
||
.cal-wd {
|
||
text-align: center;
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
}
|
||
.cal-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
}
|
||
.cal-day {
|
||
height: 38px;
|
||
text-align: center;
|
||
font-size: 0.82rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-primary);
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
}
|
||
.cal-day.overflow { color: var(--color-text-tertiary); }
|
||
|
||
/* Long-press affordance: scale + colored ring grows during the hold. */
|
||
.cal-day.startable {
|
||
cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent;
|
||
-webkit-touch-callout: none;
|
||
user-select: none;
|
||
transition: transform 100ms ease-out, box-shadow 100ms ease-out;
|
||
}
|
||
.cal-day.startable.long-pressing {
|
||
z-index: 2;
|
||
border-radius: 999px;
|
||
animation: longPressRing 600ms ease-out forwards;
|
||
}
|
||
@keyframes longPressRing {
|
||
from {
|
||
transform: scale(1);
|
||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--nord11) 70%, transparent);
|
||
}
|
||
to {
|
||
transform: scale(1.18);
|
||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--nord11) 70%, transparent);
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.cal-day.startable.long-pressing {
|
||
animation: none;
|
||
transform: scale(1.1);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--nord11) 70%, transparent);
|
||
}
|
||
}
|
||
|
||
/* --- Range shape: border-radius per position --- */
|
||
.cal-day.p-solo { border-radius: 16px; }
|
||
.cal-day.p-start { border-radius: 16px 0 0 16px; }
|
||
.cal-day.p-mid { border-radius: 0; }
|
||
.cal-day.p-end { border-radius: 0 16px 16px 0; }
|
||
|
||
/* --- Filled statuses (background-based, border matches fill) --- */
|
||
.cal-day.s-period { background: var(--nord11); color: white; font-weight: 600; }
|
||
.cal-day.s-ovulation { border: 0 solid var(--blue); font-weight: 700; background: color-mix(in srgb, var(--blue) 15%, transparent); }
|
||
.cal-day.s-ovulation.et { border-top-width: 3px; }
|
||
.cal-day.s-ovulation.eb { border-bottom-width: 3px; }
|
||
.cal-day.s-ovulation.el { border-left-width: 3px; }
|
||
.cal-day.s-ovulation.er { border-right-width: 3px; }
|
||
.cal-day.s-ovulation::before {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 4px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--blue);
|
||
}
|
||
.cal-day.s-luteal::before {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 4px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--orange);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* --- Bordered statuses: width 0 by default, edge classes set per-side width --- */
|
||
.cal-day.s-fertile { border: 0 solid var(--blue); }
|
||
.cal-day.s-fertile.et { border-top-width: 2px; }
|
||
.cal-day.s-fertile.eb { border-bottom-width: 2px; }
|
||
.cal-day.s-fertile.el { border-left-width: 2px; }
|
||
.cal-day.s-fertile.er { border-right-width: 2px; }
|
||
|
||
.cal-day.s-peak-fertile { border: 0 solid var(--blue); font-weight: 600; background: color-mix(in srgb, var(--blue) 15%, transparent); }
|
||
.cal-day.s-peak-fertile.et { border-top-width: 3px; }
|
||
.cal-day.s-peak-fertile.eb { border-bottom-width: 3px; }
|
||
.cal-day.s-peak-fertile.el { border-left-width: 3px; }
|
||
.cal-day.s-peak-fertile.er { border-right-width: 3px; }
|
||
/* Extend fertile borders into peak-fertile's rounded corner gap.
|
||
Uses background gradients instead of borders to avoid subpixel rounding misalignment. */
|
||
.cal-day.s-peak-fertile.p-start::before,
|
||
.cal-day.s-peak-fertile.p-solo::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -3px;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 16px;
|
||
pointer-events: none;
|
||
}
|
||
.cal-day.s-peak-fertile.p-start.et::before,
|
||
.cal-day.s-peak-fertile.p-solo.et::before {
|
||
top: -3px;
|
||
}
|
||
.cal-day.s-peak-fertile.p-start.eb::before,
|
||
.cal-day.s-peak-fertile.p-solo.eb::before {
|
||
bottom: -3px;
|
||
}
|
||
.cal-day.s-peak-fertile.p-start.et.eb::before,
|
||
.cal-day.s-peak-fertile.p-solo.et.eb::before {
|
||
background:
|
||
linear-gradient(var(--blue), var(--blue)) top / 100% 2px no-repeat,
|
||
linear-gradient(var(--blue), var(--blue)) bottom / 100% 2px no-repeat;
|
||
}
|
||
.cal-day.s-peak-fertile.p-start.et:not(.eb)::before,
|
||
.cal-day.s-peak-fertile.p-solo.et:not(.eb)::before {
|
||
background: linear-gradient(var(--blue), var(--blue)) top / 100% 2px no-repeat;
|
||
}
|
||
.cal-day.s-peak-fertile.p-start.eb:not(.et)::before,
|
||
.cal-day.s-peak-fertile.p-solo.eb:not(.et)::before {
|
||
background: linear-gradient(var(--blue), var(--blue)) bottom / 100% 2px no-repeat;
|
||
}
|
||
|
||
.cal-day.s-predicted { background: color-mix(in srgb, var(--nord11) 15%, transparent); border: 0 dashed var(--nord11); }
|
||
.cal-day.s-predicted.et { border-top-width: 2px; }
|
||
.cal-day.s-predicted.eb { border-bottom-width: 2px; }
|
||
.cal-day.s-predicted.el { border-left-width: 2px; }
|
||
.cal-day.s-predicted.er { border-right-width: 2px; }
|
||
|
||
/* Today marker — inner dot so it doesn't clash with range bg */
|
||
.cal-day.today { font-weight: 700; z-index: 1; }
|
||
.cal-day.today::after {
|
||
content: '';
|
||
position: absolute;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
background: var(--color-text-primary);
|
||
z-index: -1;
|
||
}
|
||
.cal-day.today { color: var(--color-bg-primary); }
|
||
|
||
/* Legend */
|
||
.cal-legend {
|
||
display: flex;
|
||
gap: 0.5rem 0.8rem;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.6rem;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.legend-dot {
|
||
width: 20px;
|
||
height: 10px;
|
||
border-radius: 5px;
|
||
flex-shrink: 0;
|
||
}
|
||
.legend-dot.period { background: var(--nord11); }
|
||
.legend-dot.predicted { background: color-mix(in srgb, var(--nord11) 15%, transparent); border: 1.5px dashed var(--nord11); box-sizing: border-box; }
|
||
.legend-dot.fertile { border: 2px solid var(--blue); box-sizing: border-box; }
|
||
.legend-dot.peak-fertile { border: 3px solid var(--blue); box-sizing: border-box; background: color-mix(in srgb, var(--blue) 15%, transparent); }
|
||
.legend-dot.ovulation { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
|
||
.legend-dot.luteal { width: 8px; height: 8px; border-radius: 50%; background: var(--orange); opacity: 0.5; }
|
||
|
||
/* Cycle stats */
|
||
.cycle-stats {
|
||
display: flex;
|
||
gap: 0.6rem;
|
||
}
|
||
.cycle-stat {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 0.6rem;
|
||
background: var(--color-surface);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.cycle-stat-label {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.cycle-stat-value {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
.cycle-stat-variance {
|
||
font-size: 0.75rem;
|
||
font-weight: 400;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* History */
|
||
.history-share-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.history-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
color: inherit;
|
||
}
|
||
.history-toggle h3 {
|
||
margin: 0;
|
||
font-size: 0.95rem;
|
||
}
|
||
.history-toggle :global(.chevron) {
|
||
transition: transform 0.2s;
|
||
}
|
||
.history-toggle :global(.chevron.open) {
|
||
transform: rotate(90deg);
|
||
}
|
||
.history h3 {
|
||
margin: 0;
|
||
font-size: 0.95rem;
|
||
}
|
||
.history-header {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.add-past-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.7rem;
|
||
padding: 0.3rem 0.5rem;
|
||
cursor: pointer;
|
||
}
|
||
.add-past-btn:hover {
|
||
border-color: var(--color-text-primary);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.add-form {
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.add-row {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.add-row label {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.add-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
.save-btn {
|
||
padding: 0.35rem 0.75rem;
|
||
background: var(--nord11);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
font-size: 0.75rem;
|
||
cursor: pointer;
|
||
}
|
||
.save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.cancel-btn {
|
||
padding: 0.35rem 0.75rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.75rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.history-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
}
|
||
.history-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: var(--color-surface);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
.history-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
}
|
||
.history-dates {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
.history-dur {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.ongoing-badge {
|
||
display: inline-block;
|
||
margin-left: 0.4rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: var(--nord11);
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.history-actions {
|
||
display: flex;
|
||
gap: 0.3rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.history-item.editing {
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.icon-btn {
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 6px;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
padding: 0.3rem;
|
||
display: flex;
|
||
opacity: 0.5;
|
||
}
|
||
.icon-btn:hover {
|
||
opacity: 1;
|
||
}
|
||
.icon-btn.edit:hover {
|
||
border-color: var(--blue);
|
||
color: var(--blue);
|
||
}
|
||
.icon-btn.delete:hover {
|
||
border-color: var(--nord11);
|
||
color: var(--nord11);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 1rem;
|
||
}
|
||
.empty-state p {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
.empty-state .add-past-btn {
|
||
margin: 0 auto 0.5rem;
|
||
}
|
||
|
||
/* Share bar (below stats) */
|
||
.share-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.shared-avatars {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
}
|
||
.shared-label {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
margin-right: 0.2rem;
|
||
}
|
||
.shared-avatar {
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 0 2px var(--color-surface);
|
||
}
|
||
.shared-avatar + .shared-avatar {
|
||
margin-left: -6px;
|
||
}
|
||
.share-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0.4rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 50%;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.share-btn:hover {
|
||
border-color: var(--blue);
|
||
color: var(--blue);
|
||
}
|
||
|
||
/* Share modal */
|
||
.share-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
.share-modal {
|
||
background: var(--color-surface);
|
||
border-radius: 12px;
|
||
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.2));
|
||
padding: 1rem;
|
||
width: min(90vw, 320px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.share-modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.share-modal-header h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
}
|
||
.share-modal-close {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
padding: 0.2rem;
|
||
display: flex;
|
||
}
|
||
.share-modal-close:hover { color: var(--color-text-primary); }
|
||
.share-user-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.share-user {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.3rem 0;
|
||
}
|
||
.share-username {
|
||
flex: 1;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
}
|
||
.chip-remove {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
padding: 0.2rem;
|
||
display: flex;
|
||
border-radius: 50%;
|
||
}
|
||
.chip-remove:hover { color: var(--nord11); background: var(--color-bg-tertiary); }
|
||
.chip-remove:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.share-empty {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
text-align: center;
|
||
padding: 0.5rem 0;
|
||
}
|
||
.share-add {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
}
|
||
.share-add input {
|
||
flex: 1;
|
||
padding: 0.4rem 0.5rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-primary);
|
||
font-size: 0.85rem;
|
||
}
|
||
.share-add input:focus {
|
||
outline: none;
|
||
border-color: var(--blue);
|
||
}
|
||
.share-add-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0.4rem;
|
||
background: var(--blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.share-add-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
/* Shared header (read-only) */
|
||
.shared-header {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
/* Read-only mode */
|
||
.read-only h2 {
|
||
font-size: 0.95rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
</style>
|