diff --git a/drizzle/0004_availability_overlap.sql b/drizzle/0004_availability_overlap.sql new file mode 100644 index 0000000..0629735 --- /dev/null +++ b/drizzle/0004_availability_overlap.sql @@ -0,0 +1,8 @@ +ALTER TABLE "profiles" ADD COLUMN "display_availability" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "availability_days" varchar(16);--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "availability_from" varchar(5);--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "availability_to" varchar(5);--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "availability_any_time" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_availability_days_check" CHECK ("profiles"."availability_days" is null or "profiles"."availability_days" in ('any', 'weekdays', 'weekends'));--> statement-breakpoint +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_availability_from_check" CHECK ("profiles"."availability_from" is null or "profiles"."availability_from" ~ '^[0-2][0-9]:[0-5][0-9]$');--> statement-breakpoint +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_availability_to_check" CHECK ("profiles"."availability_to" is null or "profiles"."availability_to" ~ '^[0-2][0-9]:[0-5][0-9]$'); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..319761d --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,603 @@ +{ + "id": "7c945a3f-a20e-44ea-aba7-c831143d15fb", + "prevId": "ce2b3ae6-fe2b-4d70-b44b-3d71f3e997aa", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profile_target_languages": { + "name": "profile_target_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "profile_target_languages_profile_id_idx": { + "name": "profile_target_languages_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_language_idx": { + "name": "profile_target_languages_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_language_idx": { + "name": "profile_target_languages_profile_language_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_position_idx": { + "name": "profile_target_languages_profile_position_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_target_languages_profile_id_profiles_id_fk": { + "name": "profile_target_languages_profile_id_profiles_id_fk", + "tableFrom": "profile_target_languages", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profile_target_languages_position_check": { + "name": "profile_target_languages_position_check", + "value": "\"profile_target_languages\".\"position\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_anonymous_copy": { + "name": "allow_anonymous_copy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_timezone": { + "name": "display_timezone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_availability": { + "name": "display_availability", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "availability_days": { + "name": "availability_days", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "availability_from": { + "name": "availability_from", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_to": { + "name": "availability_to", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_any_time": { + "name": "availability_any_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profiles_user_id_idx": { + "name": "profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_public_idx": { + "name": "profiles_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_primary_language_idx": { + "name": "profiles_primary_language_idx", + "columns": [ + { + "expression": "primary_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_target_language_idx": { + "name": "profiles_target_language_idx", + "columns": [ + { + "expression": "target_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_country_idx": { + "name": "profiles_country_idx", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_bio_length_check": { + "name": "profiles_bio_length_check", + "value": "char_length(\"profiles\".\"bio\") between 10 and 500" + }, + "profiles_availability_check": { + "name": "profiles_availability_check", + "value": "\"profiles\".\"availability\" in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')" + }, + "profiles_availability_days_check": { + "name": "profiles_availability_days_check", + "value": "\"profiles\".\"availability_days\" is null or \"profiles\".\"availability_days\" in ('any', 'weekdays', 'weekends')" + }, + "profiles_availability_from_check": { + "name": "profiles_availability_from_check", + "value": "\"profiles\".\"availability_from\" is null or \"profiles\".\"availability_from\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_availability_to_check": { + "name": "profiles_availability_to_check", + "value": "\"profiles\".\"availability_to\" is null or \"profiles\".\"availability_to\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_tags_limit_check": { + "name": "profiles_tags_limit_check", + "value": "cardinality(\"profiles\".\"tags\") <= 6" + } + }, + "isRLSEnabled": false + }, + "public.saved_profiles": { + "name": "saved_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_profiles_user_profile_idx": { + "name": "saved_profiles_user_profile_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_user_id_idx": { + "name": "saved_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_profile_id_idx": { + "name": "saved_profiles_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_profiles_user_id_users_id_fk": { + "name": "saved_profiles_user_id_users_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_profiles_profile_id_profiles_id_fk": { + "name": "saved_profiles_profile_id_profiles_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_user_id_idx": { + "name": "users_discord_user_id_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": ["beginner", "intermediate", "advanced", "native-level"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 45505c5..e54dec7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781921410375, "tag": "0003_petite_darkhawk", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781934704351, + "tag": "0004_availability_overlap", + "breakpoints": true } ] } diff --git a/src/app/[lang]/DiscoveryFeed.tsx b/src/app/[lang]/DiscoveryFeed.tsx index 6ee7c4d..0ff2403 100644 --- a/src/app/[lang]/DiscoveryFeed.tsx +++ b/src/app/[lang]/DiscoveryFeed.tsx @@ -1,3 +1,4 @@ +import type { AvailabilityPattern } from '@/constants/availability'; import { listPublicProfiles } from '@/db'; import { DiscoveryPage } from '@/features/Discovery/DiscoveryPage'; import type { DiscoveryProfile } from '@/features/Discovery/ProfileCard'; @@ -9,6 +10,8 @@ type DiscoveryFeedProps = { needsOnboarding: boolean; savedProfileIds: string[]; currentProfileId?: string; + viewerTimezone?: string; + viewerAvailability?: AvailabilityPattern; userAvatarUrl?: string; }; @@ -19,6 +22,8 @@ export const DiscoveryFeed = async ({ needsOnboarding, savedProfileIds, currentProfileId, + viewerTimezone, + viewerAvailability, userAvatarUrl, }: DiscoveryFeedProps) => { let profiles: DiscoveryProfile[] = []; @@ -41,6 +46,8 @@ export const DiscoveryFeed = async ({ profiles={profiles} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + viewerTimezone={viewerTimezone} + viewerAvailability={viewerAvailability} userAvatarUrl={userAvatarUrl} /> ); diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 9631717..bc4deb2 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -1,7 +1,9 @@ import { Suspense } from 'react'; +import type { AvailabilityPattern } from '@/constants/availability'; import { getProfileByUserId, listSavedProfileIds, + toViewerAvailabilityContext, upsertDiscordUser, } from '@/db'; import { DiscoveryPage } from '@/features/Discovery/DiscoveryPage'; @@ -21,6 +23,8 @@ export default async function Home({ let needsOnboarding = false; let savedProfileIds: string[] = []; let currentProfileId: string | undefined; + let viewerTimezone: string | undefined; + let viewerAvailability: AvailabilityPattern | undefined; if (user) { const persistedUser = await upsertDiscordUser(user); @@ -28,6 +32,12 @@ export default async function Home({ needsOnboarding = !profile; currentProfileId = profile?.profile.id; savedProfileIds = await listSavedProfileIds(persistedUser.id); + + if (profile) { + const viewer = toViewerAvailabilityContext(profile.profile); + viewerTimezone = viewer.timezone; + viewerAvailability = viewer.availability; + } } const isLoggedIn = Boolean(user); @@ -42,6 +52,8 @@ export default async function Home({ locale={lang} needsOnboarding={needsOnboarding} currentProfileId={currentProfileId} + viewerTimezone={viewerTimezone} + viewerAvailability={viewerAvailability} userAvatarUrl={user?.avatarUrl} /> } @@ -53,6 +65,8 @@ export default async function Home({ needsOnboarding={needsOnboarding} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + viewerTimezone={viewerTimezone} + viewerAvailability={viewerAvailability} userAvatarUrl={user?.avatarUrl} /> diff --git a/src/app/[lang]/profile/page.tsx b/src/app/[lang]/profile/page.tsx index 98c056f..5032bae 100644 --- a/src/app/[lang]/profile/page.tsx +++ b/src/app/[lang]/profile/page.tsx @@ -1,7 +1,9 @@ import { redirect } from 'next/navigation'; -import { availabilityPresetToPattern } from '@/constants/availability'; -import { isValidAvailability } from '@/constants/languages'; -import { getProfileByUserId, upsertDiscordUser } from '@/db'; +import { + getProfileByUserId, + mapProfileAvailability, + upsertDiscordUser, +} from '@/db'; import type { ProfileFormValues } from '@/features/Profile/schema'; import { getCurrentUser } from '@/lib/auth'; import { ProfileRouteClient } from './ProfileRouteClient'; @@ -13,13 +15,10 @@ const toProfileFormValues = ({ Awaited> >): ProfileFormValues => ({ allowAnonymousCopy: profile.allowAnonymousCopy, - availability: availabilityPresetToPattern( - isValidAvailability(profile.availability) - ? profile.availability - : 'flexible', - ), + availability: mapProfileAvailability(profile) ?? null, bio: profile.bio, country: profile.country ?? '', + displayAvailability: profile.displayAvailability, displayTimezone: profile.displayTimezone, isPublic: profile.isPublic, primaryLanguage: profile.primaryLanguage, diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index fd8b3bd..8b56aaf 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { availabilityPresetToPattern } from '@/constants/availability'; import { type ProfileTargetLanguageValue, upsertDiscordUser, @@ -37,9 +38,13 @@ export const POST = async (request: Request) => { const profile = await upsertProfileForUser(user.id, { allowAnonymousCopy: true, - availability: values.availability, + availability: + values.availability === 'flexible' + ? null + : availabilityPresetToPattern(values.availability), bio, country: values.country || null, + displayAvailability: true, displayTimezone: true, isPublic: true, primaryLanguage: values.primaryLanguage, diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 04bf4c8..2fc576a 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from 'next/server'; -import { availabilityPatternToPreset } from '@/constants/availability'; import { deleteProfileForUser, getUserByDiscordId, @@ -56,9 +55,10 @@ export const POST = async (request: Request) => { const user = await upsertDiscordUser(currentUser); const profile = await upsertProfileForUser(user.id, { allowAnonymousCopy: values.allowAnonymousCopy, - availability: availabilityPatternToPreset(values.availability), + availability: values.availability ?? null, bio: values.bio.trim(), country: values.country || null, + displayAvailability: values.displayAvailability, displayTimezone: values.displayTimezone, isPublic: values.isPublic, primaryLanguage: values.primaryLanguage, diff --git a/src/constants/availability.ts b/src/constants/availability.ts index b59e6f9..31e695d 100644 --- a/src/constants/availability.ts +++ b/src/constants/availability.ts @@ -45,31 +45,34 @@ export const availabilityPatternToPreset = ( return fromHour < 12 ? 'weekday_mornings' : 'weeknights'; }; -// Convert a wall-clock time ('HH:MM') from one IANA zone to another. +export const tzOffsetMinutes = (timezone: string): number => { + try { + const now = new Date(); + const local = new Date(now.toLocaleString('en-US', { timeZone: timezone })); + const utc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); + return Math.round((local.getTime() - utc.getTime()) / 60000); + } catch { + return 0; + } +}; + +export const parseHhMm = (value: string): number => { + const [h, m] = value.split(':').map(Number); + if (Number.isNaN(h) || Number.isNaN(m)) return 0; + return h * 60 + m; +}; + export const convertTime = ( time24: string, fromTz: string, toTz: string, ): { h: number; m: number } => { - const [h, m] = time24.split(':').map(Number); - try { - const now = new Date(); - const fromLocal = new Date( - now.toLocaleString('en-US', { timeZone: fromTz }), - ); - const utcLocal = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const toLocal = new Date(now.toLocaleString('en-US', { timeZone: toTz })); - const fromOffMs = fromLocal.getTime() - utcLocal.getTime(); - const toOffMs = toLocal.getTime() - utcLocal.getTime(); - const utcMs = h * 3600000 + m * 60000 - fromOffMs; - const totalMin = Math.round((utcMs + toOffMs) / 60000); - return { - h: ((Math.floor(totalMin / 60) % 24) + 24) % 24, - m: ((totalMin % 60) + 60) % 60, - }; - } catch { - return { h, m }; - } + const totalMin = + parseHhMm(time24) - tzOffsetMinutes(fromTz) + tzOffsetMinutes(toTz); + return { + h: ((Math.floor(totalMin / 60) % 24) + 24) % 24, + m: ((totalMin % 60) + 60) % 60, + }; }; export const fmtHour = (h: number, m: number): string => { diff --git a/src/db/account.ts b/src/db/account.ts index 9e987fa..fa1716d 100644 --- a/src/db/account.ts +++ b/src/db/account.ts @@ -2,6 +2,7 @@ import 'server-only'; import { asc, eq } from 'drizzle-orm'; import { db } from './client'; +import { mapProfileAvailability } from './profiles'; import { profiles, profileTargetLanguages, users } from './schema'; export const getAccountExportByDiscordId = async (discordUserId: string) => { @@ -50,6 +51,7 @@ export const getAccountExportByDiscordId = async (discordUserId: string) => { isPublic: profile.isPublic, allowAnonymousCopy: profile.allowAnonymousCopy, displayTimezone: profile.displayTimezone, + displayAvailability: profile.displayAvailability, primaryLanguage: profile.primaryLanguage, targetLanguage: profile.targetLanguage, targetLanguages: targetLanguages.length @@ -63,6 +65,7 @@ export const getAccountExportByDiscordId = async (discordUserId: string) => { proficiencyLevel: profile.proficiencyLevel, bio: profile.bio, availability: profile.availability, + availabilityWindow: mapProfileAvailability(profile) ?? null, tags: profile.tags, country: profile.country, timezone: profile.timezone, diff --git a/src/db/profiles.ts b/src/db/profiles.ts index 5519137..2c6fcd3 100644 --- a/src/db/profiles.ts +++ b/src/db/profiles.ts @@ -1,7 +1,11 @@ import 'server-only'; import { and, asc, desc, eq, inArray } from 'drizzle-orm'; -import { availabilityPresetToPattern } from '@/constants/availability'; +import { + type AvailabilityPattern, + availabilityPatternToPreset, + availabilityPresetToPattern, +} from '@/constants/availability'; import { isValidAvailability } from '@/constants/languages'; import type { DiscoveryProfile } from '@/features/Discovery/ProfileCard'; import type { CurrentUser } from '@/lib/auth-session'; @@ -20,14 +24,15 @@ export type ProfileValues = Pick< NewProfile, | 'allowAnonymousCopy' | 'bio' - | 'availability' | 'country' + | 'displayAvailability' | 'displayTimezone' | 'isPublic' | 'primaryLanguage' | 'tags' | 'timezone' > & { + availability: AvailabilityPattern | null; targetLanguages: ProfileTargetLanguageValue[]; }; @@ -50,6 +55,48 @@ const toUserValues = (user: CurrentUser): NewUser => ({ email: user.email, }); +const toAvailabilityPattern = ( + profile: Profile, +): AvailabilityPattern | undefined => { + if (profile.availabilityDays) { + return { + days: profile.availabilityDays as AvailabilityPattern['days'], + from: profile.availabilityFrom ?? '', + to: profile.availabilityTo ?? '', + anyTime: profile.availabilityAnyTime || undefined, + }; + } + + if ( + isValidAvailability(profile.availability) && + profile.availability !== 'flexible' + ) { + return availabilityPresetToPattern(profile.availability); + } + + return undefined; +}; + +const toAvailabilityColumns = (pattern: AvailabilityPattern | null) => { + if (!pattern) { + return { + availability: 'flexible', + availabilityDays: null, + availabilityFrom: null, + availabilityTo: null, + availabilityAnyTime: false, + }; + } + + return { + availability: availabilityPatternToPreset(pattern), + availabilityDays: pattern.days, + availabilityFrom: pattern.anyTime ? null : pattern.from, + availabilityTo: pattern.anyTime ? null : pattern.to, + availabilityAnyTime: Boolean(pattern.anyTime), + }; +}; + const toDiscoveryProfile = ({ profile, targetLanguages, @@ -67,14 +114,24 @@ const toDiscoveryProfile = ({ timezone: profile.displayTimezone ? (profile.timezone ?? undefined) : undefined, - availability: - isValidAvailability(profile.availability) && - profile.availability !== 'flexible' - ? availabilityPresetToPattern(profile.availability) - : undefined, + availability: profile.displayAvailability + ? toAvailabilityPattern(profile) + : undefined, allowAnonymousCopy: profile.allowAnonymousCopy, }); +export type ViewerAvailabilityContext = { + timezone?: string; + availability?: AvailabilityPattern; +}; + +export const toViewerAvailabilityContext = ( + profile: Profile, +): ViewerAvailabilityContext => ({ + timezone: profile.timezone ?? undefined, + availability: toAvailabilityPattern(profile), +}); + const targetLanguagesForProfile = ( profile: Profile, targetLanguages: ProfileTargetLanguageValue[] | undefined, @@ -283,7 +340,7 @@ export const upsertProfileForUser = async ( userId: string, values: ProfileValues, ) => { - const { targetLanguages, ...profileValues } = values; + const { targetLanguages, availability, ...profileValues } = values; const [primaryTarget] = targetLanguages; if (!primaryTarget) { @@ -292,6 +349,7 @@ export const upsertProfileForUser = async ( const writableProfileValues = { ...profileValues, + ...toAvailabilityColumns(availability), proficiencyLevel: primaryTarget.level, targetLanguage: primaryTarget.language, }; @@ -339,3 +397,4 @@ export const deleteProfileForUser = async (userId: string) => { }; export const mapProfileToDiscoveryProfile = toDiscoveryProfile; +export const mapProfileAvailability = toAvailabilityPattern; diff --git a/src/db/schema.ts b/src/db/schema.ts index 98a4f67..284e7b3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -49,6 +49,9 @@ export const profiles = pgTable( isPublic: boolean('is_public').default(false).notNull(), allowAnonymousCopy: boolean('allow_anonymous_copy').default(true).notNull(), displayTimezone: boolean('display_timezone').default(true).notNull(), + displayAvailability: boolean('display_availability') + .default(true) + .notNull(), primaryLanguage: varchar('primary_language', { length: 16 }).notNull(), targetLanguage: varchar('target_language', { length: 16 }).notNull(), proficiencyLevel: proficiencyLevelEnum('proficiency_level').notNull(), @@ -56,6 +59,12 @@ export const profiles = pgTable( availability: varchar('availability', { length: 32 }) .default('flexible') .notNull(), + availabilityDays: varchar('availability_days', { length: 16 }), + availabilityFrom: varchar('availability_from', { length: 5 }), + availabilityTo: varchar('availability_to', { length: 5 }), + availabilityAnyTime: boolean('availability_any_time') + .default(false) + .notNull(), tags: text('tags').array().default(sql`'{}'::text[]`).notNull(), country: varchar('country', { length: 2 }), timezone: varchar('timezone', { length: 64 }), @@ -80,6 +89,18 @@ export const profiles = pgTable( 'profiles_availability_check', sql`${table.availability} in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')`, ), + check( + 'profiles_availability_days_check', + sql`${table.availabilityDays} is null or ${table.availabilityDays} in ('any', 'weekdays', 'weekends')`, + ), + check( + 'profiles_availability_from_check', + sql`${table.availabilityFrom} is null or ${table.availabilityFrom} ~ '^[0-2][0-9]:[0-5][0-9]$'`, + ), + check( + 'profiles_availability_to_check', + sql`${table.availabilityTo} is null or ${table.availabilityTo} ~ '^[0-2][0-9]:[0-5][0-9]$'`, + ), check('profiles_tags_limit_check', sql`cardinality(${table.tags}) <= 6`), ], ); diff --git a/src/features/Discovery/DiscoveryPage.stories.tsx b/src/features/Discovery/DiscoveryPage.stories.tsx index 08fdcff..934b865 100644 --- a/src/features/Discovery/DiscoveryPage.stories.tsx +++ b/src/features/Discovery/DiscoveryPage.stories.tsx @@ -233,6 +233,45 @@ export const Sort: Story = { }, }; +export const AvailabilityFilter: Story = { + args: { + viewerTimezone: 'America/New_York', + viewerAvailability: { days: 'weekdays', from: '06:00', to: '09:00' }, + }, + render: (args) => { + const t = useTranslations('DiscoveryStories'); + + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const doc = canvasElement.ownerDocument; + + await expect(canvas.getByText('9 partners')).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole('button', { name: 'Availability' })); + + const popover = within( + await waitFor(() => { + const content = doc.querySelector('.PopoverContent'); + if (!content) throw new Error('Filter popover did not open'); + return content; + }), + ); + + await userEvent.click( + await popover.findByRole('button', { name: 'Overlaps with me' }), + ); + await userEvent.click(popover.getByRole('button', { name: 'Apply' })); + + await waitFor(() => + expect(canvas.getByText('2 partners')).toBeInTheDocument(), + ); + await expect(canvas.getAllByText('Yuki').length).toBeGreaterThan(0); + expect(canvas.queryByText('Wei')).not.toBeInTheDocument(); + }, +}; + export const Paginated: Story = { render: (args) => { const t = useTranslations('DiscoveryStories'); diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 34556ef..a357c4b 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useRef, useState } from 'react'; import { MdClose } from 'react-icons/md'; import { FilterBar } from '@/components/Filter'; +import type { AvailabilityPattern } from '@/constants/availability'; import { Navbar } from '@/features/Navbar'; import { getMissingRequiredFields, @@ -18,7 +19,11 @@ import { useDiscoveryFilterDefs, } from './discoveryFilters'; import { applyDiscoverySearch } from './discoverySearch'; -import { applyDiscoverySort, type DiscoverySortValue } from './discoverySort'; +import { + applyDiscoverySort, + type DiscoverySortValue, + SORT_OPTIONS, +} from './discoverySort'; import { applyTagFilter, buildTagCounts } from './discoveryTags'; import { buildDiscoveryQuery, parseDiscoveryState } from './discoveryUrlState'; import { Pagination } from './Pagination'; @@ -43,6 +48,8 @@ type DiscoveryPageProps = { profiles?: DiscoveryProfile[]; savedProfileIds?: string[]; currentProfileId?: string; + viewerTimezone?: string; + viewerAvailability?: AvailabilityPattern | null; userAvatarUrl?: string; }; @@ -67,13 +74,30 @@ export const DiscoveryPage = ({ profiles = [], savedProfileIds, currentProfileId, + viewerTimezone, + viewerAvailability, userAvatarUrl, }: DiscoveryPageProps) => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const t = useTranslations('Discovery'); - const filterDefs = useDiscoveryFilterDefs(); + const viewerHasAvailability = Boolean(viewerAvailability); + const viewerContext = useMemo( + () => ({ + availability: viewerAvailability ?? undefined, + timezone: viewerTimezone, + }), + [viewerAvailability, viewerTimezone], + ); + const filterDefs = useDiscoveryFilterDefs({ viewerHasAvailability }); + const sortOptions = useMemo( + () => + viewerHasAvailability + ? SORT_OPTIONS + : SORT_OPTIONS.filter((option) => option !== 'overlap-desc'), + [viewerHasAvailability], + ); const resultsHeadRef = useRef(null); const [initialState] = useState(() => parseDiscoveryState(searchParams)); const [filterValues, setFilterValues] = useState( @@ -135,15 +159,24 @@ export const DiscoveryPage = ({ applyDiscoverySort( applyTagFilter( applyDiscoverySearch( - applyDiscoveryFilters(profiles, filterValues), + applyDiscoveryFilters(profiles, filterValues, viewerContext), searchQuery, locale, ), selectedTags, ), sortValue, + viewerContext, ), - [profiles, filterValues, searchQuery, locale, selectedTags, sortValue], + [ + profiles, + filterValues, + searchQuery, + locale, + selectedTags, + sortValue, + viewerContext, + ], ); const totalPages = Math.max(1, Math.ceil(filteredProfiles.length / PER_PAGE)); @@ -272,7 +305,11 @@ export const DiscoveryPage = ({ onFilterChange={handleFilterChange} onClearFilters={handleClearFilters} sortControl={ - + } /> @@ -309,6 +346,7 @@ export const DiscoveryPage = ({ isLoggedIn={isLoggedIn} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + viewerTimezone={viewerTimezone} onSaveProfile={saveProfileRequest} emptyState={ hasActiveFilters ? undefined : t('emptyFeedDescription') diff --git a/src/features/Discovery/ProfileGrid.tsx b/src/features/Discovery/ProfileGrid.tsx index 2a2ccab..8c13094 100644 --- a/src/features/Discovery/ProfileGrid.tsx +++ b/src/features/Discovery/ProfileGrid.tsx @@ -18,6 +18,7 @@ type ProfileGridProps = { isLoggedIn?: boolean; savedProfileIds?: string[]; currentProfileId?: string; + viewerTimezone?: string; matchCriteria?: MatchCriteria | null; sortByMatchScore?: boolean; onCopyUsername?: (username: string, profileId: string) => void; @@ -83,6 +84,7 @@ export const ProfileGrid = ({ isLoggedIn = false, savedProfileIds, currentProfileId, + viewerTimezone, matchCriteria = null, sortByMatchScore = false, onCopyUsername, @@ -205,6 +207,7 @@ export const ProfileGrid = ({ }} isLoggedIn={isLoggedIn} isSaved={savedIds.has(profile.id)} + viewerTimezone={viewerTimezone} onCopyUsername={handleCopyUsername} onToggleSave={canSaveProfile ? handleToggleSave : undefined} onTagClick={onTagClick} diff --git a/src/features/Discovery/SortMenu.tsx b/src/features/Discovery/SortMenu.tsx index 0c1c3d8..bb4074b 100644 --- a/src/features/Discovery/SortMenu.tsx +++ b/src/features/Discovery/SortMenu.tsx @@ -9,6 +9,7 @@ import { type DiscoverySortValue, SORT_OPTIONS } from './discoverySort'; type SortMenuProps = { value: DiscoverySortValue; onChange: (value: DiscoverySortValue) => void; + options?: readonly DiscoverySortValue[]; }; const sortOptionLabelKeys: Record = { @@ -16,9 +17,14 @@ const sortOptionLabelKeys: Record = { 'bumped-asc': 'sortOldestBumped', 'name-asc': 'sortNameAsc', 'name-desc': 'sortNameDesc', + 'overlap-desc': 'sortMostOverlap', }; -export const SortMenu = ({ value, onChange }: SortMenuProps) => { +export const SortMenu = ({ + value, + onChange, + options = SORT_OPTIONS, +}: SortMenuProps) => { const t = useTranslations('Discovery'); const [open, setOpen] = useState(false); @@ -44,7 +50,7 @@ export const SortMenu = ({ value, onChange }: SortMenuProps) => {
{t('sortByLabel')}
- {SORT_OPTIONS.map((option) => { + {options.map((option) => { const active = option === value; return ( diff --git a/src/features/Discovery/availabilityOverlap.ts b/src/features/Discovery/availabilityOverlap.ts new file mode 100644 index 0000000..ce2ae2c --- /dev/null +++ b/src/features/Discovery/availabilityOverlap.ts @@ -0,0 +1,124 @@ +import { + type AvailabilityPattern, + parseHhMm, + tzOffsetMinutes, +} from '@/constants/availability'; + +const MINUTES_PER_DAY = 1440; +const MINUTES_PER_WEEK = 10080; + +export const MEANINGFUL_OVERLAP_MINUTES = 30; + +export type AvailabilityContext = { + availability?: AvailabilityPattern | null; + timezone?: string; +}; + +type Interval = { start: number; end: number }; + +const daysForBucket = (days: AvailabilityPattern['days']): number[] => { + if (days === 'weekdays') return [1, 2, 3, 4, 5]; + if (days === 'weekends') return [0, 6]; + return [0, 1, 2, 3, 4, 5, 6]; +}; + +const windowLength = (pattern: AvailabilityPattern): number => { + if (pattern.anyTime) return MINUTES_PER_DAY; + const from = parseHhMm(pattern.from); + const to = parseHhMm(pattern.to); + return (to - from + MINUTES_PER_DAY) % MINUTES_PER_DAY || 0; +}; + +const pushWrapped = (out: Interval[], startMinute: number, length: number) => { + let start = + ((startMinute % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK; + let remaining = length; + + while (remaining > 0) { + const segment = Math.min(remaining, MINUTES_PER_WEEK - start); + out.push({ start, end: start + segment }); + remaining -= segment; + start = 0; + } +}; + +const mergeIntervals = (intervals: Interval[]): Interval[] => { + if (intervals.length <= 1) return intervals; + + const sorted = [...intervals].sort((a, b) => a.start - b.start); + const merged: Interval[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i += 1) { + const current = sorted[i]; + const last = merged[merged.length - 1]; + if (current.start <= last.end) { + last.end = Math.max(last.end, current.end); + } else { + merged.push({ ...current }); + } + } + + return merged; +}; + +const toUtcWeekIntervals = (context: AvailabilityContext): Interval[] => { + const { availability, timezone } = context; + if (!availability) return []; + + const length = windowLength(availability); + if (length <= 0) return []; + + const offset = timezone ? tzOffsetMinutes(timezone) : 0; + const fromMinute = availability.anyTime ? 0 : parseHhMm(availability.from); + const intervals: Interval[] = []; + + for (const day of daysForBucket(availability.days)) { + const localStart = day * MINUTES_PER_DAY + fromMinute; + pushWrapped(intervals, localStart - offset, length); + } + + return mergeIntervals(intervals); +}; + +const intersectionMinutes = (a: Interval[], b: Interval[]): number => { + let total = 0; + let i = 0; + let j = 0; + + while (i < a.length && j < b.length) { + const start = Math.max(a[i].start, b[j].start); + const end = Math.min(a[i].end, b[j].end); + if (end > start) total += end - start; + if (a[i].end < b[j].end) i += 1; + else j += 1; + } + + return total; +}; + +const currentWeekMinuteUtc = (now: Date): number => + now.getUTCDay() * MINUTES_PER_DAY + + now.getUTCHours() * 60 + + now.getUTCMinutes(); + +export const overlapMinutes = ( + viewer: AvailabilityContext, + candidate: AvailabilityContext, +): number => + intersectionMinutes( + toUtcWeekIntervals(viewer), + toUtcWeekIntervals(candidate), + ); + +export const isAvailableNow = ( + candidate: AvailabilityContext, + now: Date = new Date(), +): boolean => { + const intervals = toUtcWeekIntervals(candidate); + if (intervals.length === 0) return false; + + const minute = currentWeekMinuteUtc(now); + return intervals.some( + (interval) => minute >= interval.start && minute < interval.end, + ); +}; diff --git a/src/features/Discovery/discoveryFilters.ts b/src/features/Discovery/discoveryFilters.ts index 61c90c5..33b97a3 100644 --- a/src/features/Discovery/discoveryFilters.ts +++ b/src/features/Discovery/discoveryFilters.ts @@ -1,9 +1,10 @@ -import { useLocale } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { useMemo } from 'react'; import { MdAccessTime, MdLanguage, MdLocationOn, + MdSchedule, MdSchool, } from 'react-icons/md'; import type { FilterConfig } from '@/components/Filter/FilterBar'; @@ -13,10 +14,26 @@ import { languageOptions, proficiencyOptions, } from '@/constants/languages'; +import { + type AvailabilityContext, + isAvailableNow, + MEANINGFUL_OVERLAP_MINUTES, + overlapMinutes, +} from './availabilityOverlap'; import type { DiscoveryProfile } from './ProfileCard'; export type DiscoveryFilterValues = Record; +export const AVAILABILITY_AVAILABLE_NOW = 'available-now'; +export const AVAILABILITY_OVERLAPS = 'overlaps'; + +const toAvailabilityContext = ( + profile: DiscoveryProfile, +): AvailabilityContext => ({ + availability: profile.availability, + timezone: profile.timezone, +}); + // The timezone picker mirrors the runtime's IANA zones so values stay aligned // with what profiles persist. Guarded for engines without supportedValuesOf. export const getTimezoneFilterOptions = (): { @@ -38,10 +55,12 @@ export const getTimezoneFilterOptions = (): { return []; }; -// The five discovery filters from the design, in row order. Shared by the -// discovery page and the FilterBar story so definitions stay in one place. -export const useDiscoveryFilterDefs = (): FilterConfig[] => { +export const useDiscoveryFilterDefs = (options?: { + viewerHasAvailability?: boolean; +}): FilterConfig[] => { const locale = useLocale(); + const t = useTranslations('Discovery'); + const viewerHasAvailability = options?.viewerHasAvailability ?? false; return useMemo( () => [ @@ -84,8 +103,25 @@ export const useDiscoveryFilterDefs = (): FilterConfig[] => { options: getTimezoneFilterOptions(), multiple: true, }, + { + id: 'availability', + icon: MdSchedule, + labelKey: 'filterAvailability', + placeholderKey: 'filterSelectAvailability', + options: [ + { value: AVAILABILITY_AVAILABLE_NOW, label: t('filterAvailableNow') }, + ...(viewerHasAvailability + ? [ + { + value: AVAILABILITY_OVERLAPS, + label: t('filterOverlapsWithMe'), + }, + ] + : []), + ], + }, ], - [locale], + [locale, t, viewerHasAvailability], ); }; @@ -99,12 +135,14 @@ const toSelection = (value: string | string[] | undefined): string[] => { export const applyDiscoveryFilters = ( profiles: DiscoveryProfile[], values: DiscoveryFilterValues, + viewer?: AvailabilityContext, ): DiscoveryProfile[] => { const primaryLanguage = toSelection(values.primaryLanguage); const targetLanguage = toSelection(values.targetLanguage); const country = toSelection(values.country); const proficiency = toSelection(values.proficiency); const timezone = toSelection(values.timezone); + const availability = toSelection(values.availability)[0]; return profiles.filter((profile) => { if ( @@ -146,6 +184,22 @@ export const applyDiscoveryFilters = ( return false; } + if ( + availability === AVAILABILITY_AVAILABLE_NOW && + !isAvailableNow(toAvailabilityContext(profile)) + ) { + return false; + } + + if ( + availability === AVAILABILITY_OVERLAPS && + (!viewer || + overlapMinutes(viewer, toAvailabilityContext(profile)) < + MEANINGFUL_OVERLAP_MINUTES) + ) { + return false; + } + return true; }); }; diff --git a/src/features/Discovery/discoverySort.ts b/src/features/Discovery/discoverySort.ts index eebf73e..c51b53a 100644 --- a/src/features/Discovery/discoverySort.ts +++ b/src/features/Discovery/discoverySort.ts @@ -1,3 +1,7 @@ +import { + type AvailabilityContext, + overlapMinutes, +} from './availabilityOverlap'; import type { DiscoveryProfile } from './ProfileCard'; export const SORT_OPTIONS = [ @@ -5,6 +9,7 @@ export const SORT_OPTIONS = [ 'bumped-asc', 'name-asc', 'name-desc', + 'overlap-desc', ] as const; export type DiscoverySortValue = (typeof SORT_OPTIONS)[number]; @@ -17,6 +22,7 @@ const bumpRank = (profile: DiscoveryProfile): number => export const applyDiscoverySort = ( profiles: DiscoveryProfile[], sort: DiscoverySortValue, + viewer?: AvailabilityContext, ): DiscoveryProfile[] => { const sorted = [...profiles]; @@ -37,5 +43,24 @@ export const applyDiscoverySort = ( return sorted.sort((a, b) => a.displayName.localeCompare(b.displayName)); case 'name-desc': return sorted.sort((a, b) => b.displayName.localeCompare(a.displayName)); + case 'overlap-desc': { + const overlap = new Map( + sorted.map((profile) => [ + profile.id, + viewer + ? overlapMinutes(viewer, { + availability: profile.availability, + timezone: profile.timezone, + }) + : 0, + ]), + ); + return sorted.sort( + (a, b) => + (overlap.get(b.id) ?? 0) - (overlap.get(a.id) ?? 0) || + bumpRank(a) - bumpRank(b) || + a.displayName.localeCompare(b.displayName), + ); + } } }; diff --git a/src/features/Discovery/discoveryUrlState.ts b/src/features/Discovery/discoveryUrlState.ts index d6ae539..17a2701 100644 --- a/src/features/Discovery/discoveryUrlState.ts +++ b/src/features/Discovery/discoveryUrlState.ts @@ -22,6 +22,8 @@ const SORT_PARAM = 'sort'; const PAGE_PARAM = 'page'; const PROFICIENCY_PARAM = 'level'; const PROFICIENCY_FILTER_ID = 'proficiency'; +const AVAILABILITY_PARAM = 'avail'; +const AVAILABILITY_FILTER_ID = 'availability'; const MULTI_FILTER_PARAMS = [ ['primaryLanguage', 'primary'], @@ -36,6 +38,7 @@ const MANAGED_PARAMS = [ SORT_PARAM, PAGE_PARAM, PROFICIENCY_PARAM, + AVAILABILITY_PARAM, ...MULTI_FILTER_PARAMS.map(([, param]) => param), ]; @@ -67,6 +70,11 @@ export const parseDiscoveryState = ( filterValues[PROFICIENCY_FILTER_ID] = proficiency; } + const availability = params.get(AVAILABILITY_PARAM); + if (availability) { + filterValues[AVAILABILITY_FILTER_ID] = availability; + } + const sort = params.get(SORT_PARAM); return { @@ -103,6 +111,11 @@ export const buildDiscoveryQuery = ( params.set(PROFICIENCY_PARAM, proficiency); } + const availability = toFirstValue(state.filterValues[AVAILABILITY_FILTER_ID]); + if (availability) { + params.set(AVAILABILITY_PARAM, availability); + } + for (const tag of state.selectedTags) { params.append(TAGS_PARAM, tag); } diff --git a/src/features/Profile/ProfilePage.stories.tsx b/src/features/Profile/ProfilePage.stories.tsx index c948c21..6f3099b 100644 --- a/src/features/Profile/ProfilePage.stories.tsx +++ b/src/features/Profile/ProfilePage.stories.tsx @@ -33,6 +33,7 @@ const sampleProfile: ProfileFormValues = { isPublic: true, allowAnonymousCopy: true, displayTimezone: true, + displayAvailability: true, availability: { days: 'weekdays', from: '06:00', to: '09:00' }, bio: 'I am a graphic designer in Osaka looking for a patient partner to practice everyday English with.', tags: ['Anime', 'Cooking', 'Photography'], diff --git a/src/features/Profile/ProfilePage.tsx b/src/features/Profile/ProfilePage.tsx index 9b0bf06..6544247 100644 --- a/src/features/Profile/ProfilePage.tsx +++ b/src/features/Profile/ProfilePage.tsx @@ -66,6 +66,7 @@ const defaultValues: ProfileFormValues = { targetLanguages: [createEmptyLanguageRow()], allowAnonymousCopy: true, displayTimezone: true, + displayAvailability: true, isPublic: true, bio: '', availability: availabilityPresetToPattern('flexible'), @@ -182,6 +183,7 @@ export const ProfilePage: React.FC = ({ }); const displayTimezone = watch('displayTimezone'); + const displayAvailability = watch('displayAvailability'); const allowAnonymousCopy = watch('allowAnonymousCopy'); const primaryLanguage = watch('primaryLanguage'); const targetLanguages = watch('targetLanguages') ?? []; @@ -306,7 +308,9 @@ export const ProfilePage: React.FC = ({ lastBumpRelative: t('previewJustNow'), premium: previewIsPremiumLook, cardTheme: previewTheme, - availability: availability ?? undefined, + availability: displayAvailability + ? (availability ?? undefined) + : undefined, voiceIntroSeconds: premium ? voiceIntroSeconds : PREVIEW_TEASE_VOICE_SECONDS, @@ -317,6 +321,7 @@ export const ProfilePage: React.FC = ({ bio, country, displayName, + displayAvailability, displayTimezone, localizedCountryOptions, premium, @@ -711,6 +716,22 @@ export const ProfilePage: React.FC = ({ )} /> + + + ( + + )} + /> + diff --git a/src/features/Profile/schema.ts b/src/features/Profile/schema.ts index 54a876d..d9696df 100644 --- a/src/features/Profile/schema.ts +++ b/src/features/Profile/schema.ts @@ -10,6 +10,7 @@ export const profileSchema = z.object({ isPublic: z.boolean(), allowAnonymousCopy: z.boolean(), displayTimezone: z.boolean(), + displayAvailability: z.boolean(), primaryLanguage: z.string().min(1, { message: 'primaryLanguageRequired' }), diff --git a/src/locales/en.json b/src/locales/en.json index 4d5c77c..c62c715 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -118,6 +118,8 @@ "allowAnonymousCopyingDescription": "Let users who aren't logged in copy your Discord username.", "displayTimezoneLabel": "Display timezone", "displayTimezoneDescription": "Show your timezone on your public profile.", + "displayAvailabilityLabel": "Display availability", + "displayAvailabilityDescription": "Show your weekly availability on your public profile.", "statusPublic": "Public", "statusUnlisted": "Unlisted", "makeProfilePublicLabel": "Make Profile Public", @@ -343,6 +345,7 @@ "sortOldestBumped": "Oldest bumped", "sortNameAsc": "Name (A-Z)", "sortNameDesc": "Name (Z-A)", + "sortMostOverlap": "Most overlap", "paginationLabel": "Page {page} of {total}", "paginationPrevious": "Previous page", "paginationNext": "Next page", @@ -352,6 +355,7 @@ "filterTags": "Tags", "filterProficiency": "Proficiency", "filterTimezone": "Timezone", + "filterAvailability": "Availability", "filterSelectLanguage": "Select language", "filterSelectCountry": "Select country", "filterCancel": "Cancel", @@ -359,6 +363,9 @@ "filterSelectTag": "Select tag", "filterSelectLevel": "Select level", "filterSelectTimezone": "Select timezone", + "filterSelectAvailability": "Select availability", + "filterAvailableNow": "Available now", + "filterOverlapsWithMe": "Overlaps with me", "filterSearchPlaceholder": "Search...", "filterClear": "Clear filters", "filterNoResults": "No results found", diff --git a/src/locales/ja.json b/src/locales/ja.json index 6912bff..2bc8e8c 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -118,6 +118,8 @@ "allowAnonymousCopyingDescription": "ログインしていないユーザーが自分のDiscordユーザー名をコピーできるようにする。", "displayTimezoneLabel": "タイムゾーンを表示", "displayTimezoneDescription": "公開プロフィールにタイムゾーンを表示する。", + "displayAvailabilityLabel": "空き時間を表示", + "displayAvailabilityDescription": "公開プロフィールに毎週の空き時間を表示する。", "statusPublic": "公開", "statusUnlisted": "未掲載", "makeProfilePublicLabel": "プロフィールを公開する", @@ -343,6 +345,7 @@ "sortOldestBumped": "バンプが古い順", "sortNameAsc": "名前 (A-Z)", "sortNameDesc": "名前 (Z-A)", + "sortMostOverlap": "時間が合う順", "paginationLabel": "{page}/{total}", "paginationPrevious": "前のページ", "paginationNext": "次のページ", @@ -352,6 +355,7 @@ "filterTags": "タグ", "filterProficiency": "習熟度", "filterTimezone": "タイムゾーン", + "filterAvailability": "空き時間", "filterSelectLanguage": "言語を選択", "filterSelectCountry": "国を選択", "filterCancel": "キャンセル", @@ -359,6 +363,9 @@ "filterSelectTag": "タグを選択", "filterSelectLevel": "レベルを選択", "filterSelectTimezone": "タイムゾーンを選択", + "filterSelectAvailability": "空き時間を選択", + "filterAvailableNow": "現在対応可能", + "filterOverlapsWithMe": "自分と時間が合う", "filterSearchPlaceholder": "検索...", "filterClear": "フィルターをクリア", "filterNoResults": "結果が見つかりません",