Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,7 @@ async function measureScroll(page: Page): Promise<ScrollObservation> {
const root =
document.querySelector<HTMLElement>(selector) ?? document.documentElement;
const maxScroll = Math.max(0, root.scrollHeight - root.clientHeight);
const scrollY =
root === document.documentElement ? window.scrollY : root.scrollTop;
const scrollY = root === document.documentElement ? window.scrollY : root.scrollTop;

const intersectionRatio = await new Promise<number>((resolve) => {
const io = new IntersectionObserver(
Expand Down
6 changes: 5 additions & 1 deletion src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export function Layout() {
</div>
</main>

<div className={styles.scrollBottomSpacer} data-testid="scroll-bottom-spacer" aria-hidden />
<div
className={styles.scrollBottomSpacer}
data-testid="scroll-bottom-spacer"
aria-hidden
/>

<nav className={styles.bottomNav} data-testid="bottom-nav">
<NavLink
Expand Down
3 changes: 2 additions & 1 deletion src/components/PrCard/PrCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Globe, Percent, Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FEATURES } from "@/config/features";
import type { PrRecord } from "@/types/pr";
import styles from "./PrCard.module.css";

Expand All @@ -25,7 +26,7 @@ export function PrCard({ record, isBestPr, onEdit, onDelete, onCalculator }: Pro
<div className={styles.top}>
<h3 className={styles.exercise}>{record.exercise}</h3>
<div className={styles.badges}>
{record.isPublic && (
{FEATURES.visibility && record.isPublic && (
<span className={styles.publicBadge} title={t("pr.publicBadge")}>
<Globe size={12} />
</span>
Expand Down
43 changes: 24 additions & 19 deletions src/components/PrFilters/PrFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Filter, X } from "lucide-react";
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Select } from "@/components/Select/Select";
import { FEATURES } from "@/config/features";
import { EXERCISE_GROUPS } from "@/lib/statistics";
import {
DEFAULT_PR_FILTERS,
countActiveFilters,
hasActiveFilters,
uiFilters,
type PrFiltersState,
} from "@/lib/prFilters";
import styles from "./PrFilters.module.css";
Expand Down Expand Up @@ -44,8 +46,9 @@ export function PrFilters({
}: Props) {
const { t, i18n } = useTranslation();
const panelDir = i18n.dir();
const active = hasActiveFilters(filters);
const activeCount = countActiveFilters(filters);
const safeFilters = uiFilters(filters);
const active = hasActiveFilters(safeFilters);
const activeCount = countActiveFilters(safeFilters);
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -94,7 +97,7 @@ export function PrFilters({
}, [open, onOpenChange]);

function patch(partial: Partial<PrFiltersState>) {
const next = { ...filters, ...partial };
const next = { ...safeFilters, ...partial };
if (partial.groupId !== undefined && partial.groupId !== filters.groupId) {
next.exercise = "";
}
Expand Down Expand Up @@ -160,7 +163,7 @@ export function PrFilters({
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange({ ...DEFAULT_PR_FILTERS })}
onClick={() => onChange(uiFilters(DEFAULT_PR_FILTERS))}
>
<X size={14} />
{t("filters.clear")}
Expand Down Expand Up @@ -217,21 +220,23 @@ export function PrFilters({
</div>
</div>

<div className={styles.field}>
<label className="label" htmlFor="filter-visibility">
{t("filters.visibility")}
</label>
<Select
id="filter-visibility"
value={filters.visibility}
onValueChange={(visibility) =>
patch({
visibility: visibility as PrFiltersState["visibility"],
})
}
options={visibilityOptions}
/>
</div>
{FEATURES.visibility && (
<div className={styles.field}>
<label className="label" htmlFor="filter-visibility">
{t("filters.visibility")}
</label>
<Select
id="filter-visibility"
value={filters.visibility}
onValueChange={(visibility) =>
patch({
visibility: visibility as PrFiltersState["visibility"],
})
}
options={visibilityOptions}
/>
</div>
)}

<div className={styles.checks}>
<label className={styles.checkLabel}>
Expand Down
37 changes: 20 additions & 17 deletions src/components/PrForm/PrForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Loader } from "@/components/Loader/Loader";
import { FEATURES } from "@/config/features";
import { validatePrInput, type ValidationErrorCode } from "@/lib/validation";
import { PrValidationError } from "@/services/prService";
import type { PrInput, PrRecord } from "@/types/pr";
Expand Down Expand Up @@ -68,7 +69,7 @@ export function PrForm({
reps: repCount,
date,
notes: notes.trim() || undefined,
isPublic,
isPublic: FEATURES.visibility ? isPublic : false,
});

if (!result.ok) {
Expand Down Expand Up @@ -181,23 +182,25 @@ export function PrForm({
/>
</div>

<div className={styles.visibility}>
<div className={styles.visibilityText}>
<span className={styles.visibilityLabel}>{t("pr.visibility")}</span>
<span className={styles.visibilityHint}>
{isPublic ? t("pr.visibilityPublicHint") : t("pr.visibilityPrivateHint")}
</span>
{FEATURES.visibility && (
<div className={styles.visibility}>
<div className={styles.visibilityText}>
<span className={styles.visibilityLabel}>{t("pr.visibility")}</span>
<span className={styles.visibilityHint}>
{isPublic ? t("pr.visibilityPublicHint") : t("pr.visibilityPrivateHint")}
</span>
</div>
<button
type="button"
role="switch"
aria-checked={isPublic}
className={`${styles.toggle} ${isPublic ? styles.toggleOn : ""}`}
onClick={() => setIsPublic((v) => !v)}
>
<span className={styles.toggleThumb} />
</button>
</div>
<button
type="button"
role="switch"
aria-checked={isPublic}
className={`${styles.toggle} ${isPublic ? styles.toggleOn : ""}`}
onClick={() => setIsPublic((v) => !v)}
>
<span className={styles.toggleThumb} />
</button>
</div>
)}

{error && <p className="error-text">{error}</p>}

Expand Down
7 changes: 7 additions & 0 deletions src/config/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const FEATURES = {
/**
* Public/private sharing is experimental.
* When disabled, all UI and behavior related to visibility is hidden and writes are forced private.
*/
visibility: import.meta.env.VITE_ENABLE_VISIBILITY === "true",
} as const;
11 changes: 5 additions & 6 deletions src/lib/prFilters.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { FEATURES } from "@/config/features";
import { applyPrFilters, countActiveFilters, DEFAULT_PR_FILTERS } from "./prFilters";
import type { PrRecord } from "@/types/pr";

Expand Down Expand Up @@ -67,16 +68,14 @@ describe("applyPrFilters", () => {
});

it("filters public and with comments", () => {
// Visibility filtering is gated behind a feature flag.
// When disabled, "public/private" behaves like "all".
const result = applyPrFilters(
records,
{
...DEFAULT_PR_FILTERS,
visibility: "public",
withCommentsOnly: true,
},
{ ...DEFAULT_PR_FILTERS, visibility: "public", withCommentsOnly: true },
bestIds
);
expect(result).toHaveLength(0);
expect(result).toHaveLength(FEATURES.visibility ? 0 : 1);

const withNotes = applyPrFilters(
records,
Expand Down
15 changes: 12 additions & 3 deletions src/lib/prFilters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EXERCISE_GROUPS } from "@/lib/statistics";
import { FEATURES } from "@/config/features";
import type { PrRecord } from "@/types/pr";

export type VisibilityFilter = "all" | "public" | "private";
Expand All @@ -23,6 +24,12 @@ export const DEFAULT_PR_FILTERS: PrFiltersState = {
withCommentsOnly: false,
};

/** Normalize filters for UI when visibility feature is disabled. */
export function uiFilters(filters: PrFiltersState): PrFiltersState {
if (FEATURES.visibility) return filters;
return { ...filters, visibility: "all" };
}

export function hasActiveFilters(filters: PrFiltersState): boolean {
return countActiveFilters(filters) > 0;
}
Expand All @@ -34,7 +41,7 @@ export function countActiveFilters(filters: PrFiltersState): number {
if (filters.dateFrom !== "") count++;
if (filters.dateTo !== "") count++;
if (filters.onlyHighest) count++;
if (filters.visibility !== "all") count++;
if (FEATURES.visibility && filters.visibility !== "all") count++;
if (filters.withCommentsOnly) count++;
return count;
}
Expand All @@ -57,8 +64,10 @@ export function applyPrFilters(
if (filters.dateFrom && record.date < filters.dateFrom) return false;
if (filters.dateTo && record.date > filters.dateTo) return false;
if (filters.onlyHighest && !bestPrIds.has(record.id)) return false;
if (filters.visibility === "public" && !record.isPublic) return false;
if (filters.visibility === "private" && record.isPublic) return false;
if (FEATURES.visibility) {
if (filters.visibility === "public" && !record.isPublic) return false;
if (filters.visibility === "private" && record.isPublic) return false;
}
if (filters.withCommentsOnly && !record.notes?.trim()) return false;
return true;
});
Expand Down
6 changes: 5 additions & 1 deletion src/pages/Statistics/StatisticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ export function StatisticsPage() {
{groupStats.map((g) => {
const best = g.best;
return (
<li key={g.id} className={styles.groupCard} data-testid="stats-group-card">
<li
key={g.id}
className={styles.groupCard}
data-testid="stats-group-card"
>
<div className={styles.groupTop}>
<span className={styles.groupName}>{t(g.labelKey)}</span>
<span className={styles.groupCount}>
Expand Down
4 changes: 3 additions & 1 deletion src/services/prService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
updateDoc,
type Unsubscribe,
} from "firebase/firestore";
import { FEATURES } from "@/config/features";
import { db } from "@/lib/firebase";
import { validatePrInput, type ValidationErrorCode } from "@/lib/validation";
import type { PrInput, PrRecord } from "@/types/pr";
Expand All @@ -25,7 +26,8 @@ export class PrValidationError extends Error {
}

function assertValidInput(input: PrInput): PrInput {
const result = validatePrInput(input);
const gated: PrInput = FEATURES.visibility ? input : { ...input, isPublic: false };
const result = validatePrInput(gated);
if (!result.ok) throw new PrValidationError(result.code);
return result.value;
}
Expand Down
1 change: 1 addition & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ declare const __APP_VERSION__: string;

interface ImportMetaEnv {
readonly VITE_E2E_MOCK?: string;
readonly VITE_ENABLE_VISIBILITY?: string;
readonly VITE_FIREBASE_API_KEY: string;
readonly VITE_FIREBASE_AUTH_DOMAIN: string;
readonly VITE_FIREBASE_PROJECT_ID: string;
Expand Down
Loading