refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s

OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
2026-09-22 22:11:02 +03:00
parent c86be7bf73
commit 85fcfe3d07
101 changed files with 1487 additions and 666 deletions
@@ -23,8 +23,9 @@ import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -6,12 +6,17 @@ import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
moscowDate,
parseDraftDate,
} from "../model/period";
const ISO_MONTH_LENGTH = 7;
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
type IncomeDisplayModel = {
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
@@ -41,7 +46,6 @@ type AccountIncomePageViewModel =
onSubmit: (values: IncomeForm) => void;
};
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
@@ -129,18 +133,6 @@ const useAccountIncomePageViewModel = (
};
};
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-");
return `${day}.${month}.${year}`;
}
function parseDraftDate(value: string): string | null {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
const [day, month, year] = value.split(".");
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : null;
}
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
@@ -163,15 +155,6 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
);
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
): IncomeDisplayModel | null {
@@ -276,26 +259,9 @@ function formatIncomeChange(
}).format(ratio);
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(value);
const part = (type: string) =>
parts.find((item) => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
export {
type AccountIncomePageViewModel,
formatDraftDate,
type IncomeDisplayModel,
type IncomeForm,
isDate,
moscowDate,
parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
@@ -7,10 +7,10 @@ import {
Text,
Title,
} from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view";
@@ -1,7 +1,7 @@
import { Paper, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { FormProvider, useForm, useFormState } from "react-hook-form";
import type { IncomeForm } from "../../model/period";
import { IncomePeriodForm } from "./IncomePeriodForm.view";
const period = { from: "01.08.2026", to: "27.08.2026" };
const errors = {
to: { type: "manual", message: "Синтетическая ошибка периода" },
};
type PeriodExampleProps = { invalid?: boolean };
const PeriodExample: FC<PeriodExampleProps> = (props) => {
const methods = useForm<IncomeForm>({
defaultValues: period,
errors: props.invalid ? errors : undefined,
});
const { isSubmitSuccessful } = useFormState({ control: methods.control });
const submitted = isSubmitSuccessful ? methods.getValues() : null;
return (
<FormProvider {...methods}>
<IncomePeriodForm
onSubmit={methods.handleSubmit(() => undefined)}
onReset={() => {
methods.reset(period);
}}
/>
<output aria-live="polite">
{submitted
? `Применено: ${submitted.from} – ${submitted.to}`
: "Период не применён"}
</output>
</FormProvider>
);
};
const meta = {
title: "Product/IncomePeriodForm",
component: PeriodExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
},
},
},
render: () => <PeriodExample />,
} satisfies Meta<typeof PeriodExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const ValidationError: Story = {
render: () => <PeriodExample invalid />,
};
@@ -3,13 +3,13 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import classes from "../AccountIncomePage.module.css";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
} from "../AccountIncomePage.vm";
} from "../../model/period";
import classes from "../AccountIncomePage.module.css";
type IncomePeriodFormProps = {
onSubmit: FormEventHandler<HTMLFormElement>;