Add My Bar, Bartender, Recommend features + drink images

- Drink Images: upload/display photos of bottles/cans on drink cards and detail pages
- My Bar: inventory tracker for spirits, liqueurs, mixers, bitters, garnishes, tools
- Bartender: AI-powered cocktail recipe generation, "what can I make" suggestions,
  saved recipes. Cross-references bar inventory for ingredient availability.
- Recommend: AI flavor profile analysis, personalized drink recommendations,
  "find similar" drinks based on highly-rated favorites
- Navigation: desktop sidebar with all 8 routes, mobile bottom nav with
  4 primary items + "More" popup menu
- New Prisma models: BarItem, Recipe, FlavorProfile
- Backup/restore updated to include bar items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 18:28:02 -07:00
parent d8f069cce4
commit 2ac2c4b2d4
40 changed files with 3709 additions and 11 deletions

View File

@@ -166,3 +166,119 @@ Do not include any text before or after the JSON array. Example format:
}
]`
}
export const COCKTAIL_RECIPE_PROMPT = `You are an expert bartender and cocktail specialist. Generate a detailed cocktail recipe.
The user's home bar inventory is provided. For each ingredient, indicate whether the user has it.
## User's Bar Inventory
{barInventory}
## Instructions
Create a recipe for the requested cocktail. Return a valid JSON object:
- "title" (string): Cocktail name
- "ingredients" (array): Each { "name": string, "amount": string, "available": boolean }
- "steps" (string array): Step-by-step instructions
- "garnish" (string, optional): Garnish description
- "glassware" (string, optional): Recommended glass
- "notes" (string, optional): Tips, variations, or history
Mark ingredients available:true only if matching item exists in bar inventory. Do not include text before or after the JSON.`
export const WHAT_CAN_I_MAKE_PROMPT = `You are an expert bartender. Based on the user's bar inventory, suggest cocktails they can make.
## User's Bar Inventory
{barInventory}
## Instructions
Suggest cocktails prioritizing those where ALL ingredients are available, then those missing 1-2 ingredients.
Return a valid JSON array of objects:
- "title" (string): Cocktail name
- "ingredients" (array): Each { "name": string, "amount": string, "available": boolean }
- "steps" (string array): Brief preparation steps
- "garnish" (string, optional)
- "glassware" (string, optional)
- "missingCount" (number): How many ingredients missing (0 = can make now)
Sort by missingCount ascending. Return up to 10. Do not include text before or after the JSON.`
export function buildBarInventoryString(items: { name: string; category: string; quantity: string }[]): string {
const byCategory: Record<string, string[]> = {}
for (const item of items) {
if (item.quantity === "EMPTY") continue
if (!byCategory[item.category]) byCategory[item.category] = []
byCategory[item.category].push(`${item.name} (${item.quantity.toLowerCase()})`)
}
return Object.entries(byCategory)
.map(([cat, items]) => `### ${cat}\n${items.map(i => `- ${i}`).join('\n')}`)
.join('\n\n') || 'No items in bar inventory.'
}
export const FLAVOR_PROFILE_PROMPT = `You are an expert sommelier and drink taste profiler. Analyze the user's drink history and ratings to build a flavor profile.
## User's Rated Drinks
{drinkHistory}
## Instructions
Create a comprehensive flavor profile. Return a valid JSON object:
- "summary" (string): 2-3 sentence natural language summary
- "topFlavors" (string array): Top 5-8 flavor descriptors they prefer
- "avoidFlavors" (string array): Flavors they tend to dislike
- "preferredTypes" (string array): Preferred drink types/styles
- "adventureScore" (number, 0-1): How adventurous/varied their choices are
Do not include text before or after the JSON.`
export const RECOMMEND_DRINK_PROMPT = `You are a drink recommendation expert. Based on the user's flavor profile and context, suggest drinks they'd enjoy.
## User's Flavor Profile
{flavorProfile}
## Additional Context
{context}
## Instructions
Recommend 3-5 drinks. Return a valid JSON array of objects:
- "name" (string): Specific drink name
- "type" (string): BEER, WINE, COCKTAIL, SPIRIT, or OTHER
- "subType" (string, optional): Style
- "brewery" (string, optional): Producer
- "reason" (string): 1-2 sentence personalized explanation
- "matchScore" (number, 0-1): How well it matches their profile
Sort by matchScore descending. Do not include text before or after the JSON.`
export const SIMILAR_DRINK_PROMPT = `You are a drink expert. The user loves a specific drink and wants similar ones.
## Drink They Love
{sourceDrink}
## User's Flavor Profile
{flavorProfile}
## Instructions
Suggest 3-5 similar drinks. Return a valid JSON array of objects:
- "name" (string): Specific drink name
- "type" (string): BEER, WINE, COCKTAIL, SPIRIT, or OTHER
- "subType" (string, optional): Style
- "brewery" (string, optional): Producer
- "reason" (string): Why it's similar and why they'd like it
- "similarity" (number, 0-1): How similar to the source drink
Sort by similarity descending. Do not include text before or after the JSON.`
export function buildDrinkHistoryString(drinks: { name: string; type: string; subType?: string | null; brewery?: string | null; avgRating: number | null; ratingCount: number; wouldReorder: boolean }[]): string {
return drinks
.filter(d => d.avgRating !== null)
.sort((a, b) => (b.avgRating ?? 0) - (a.avgRating ?? 0))
.map(d => {
const parts = [`${d.name} (${d.type})`]
if (d.subType) parts.push(`Style: ${d.subType}`)
if (d.brewery) parts.push(`From: ${d.brewery}`)
parts.push(`Rating: ${d.avgRating}/5 (${d.ratingCount} rating${d.ratingCount !== 1 ? 's' : ''})`)
if (d.wouldReorder) parts.push('Would reorder: Yes')
return `- ${parts.join(' | ')}`
})
.join('\n') || 'No rated drinks yet.'
}

View File

@@ -42,6 +42,8 @@ export interface DrinkSearchResult {
export interface AIProvider {
name: string
sendTextRequest(systemPrompt: string, userMessage: string): Promise<string>
sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise<string>
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
recommendDrinks(
extractedItems: ExtractedMenuItem[],

View File

@@ -6,6 +6,7 @@ import type {
WishlistItem,
UserPreference,
SharedList,
BarItem,
} from "@prisma/client"
import crypto from "crypto"
@@ -39,6 +40,8 @@ const CSV_HEADERS = [
"minAbv",
"maxAbv",
"defaultProvider",
"category",
"quantity",
"createdAt",
"updatedAt",
]
@@ -87,6 +90,16 @@ export interface ParsedWishlistItem {
updatedAt?: Date
}
export interface ParsedBarItem {
_originalId: string
name: string
category: string
quantity: string
notes?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedPreferences {
preferredStyles: string[]
avoidedStyles: string[]
@@ -112,6 +125,7 @@ export interface ParsedBackupData {
wishlistItems: ParsedWishlistItem[]
preferences: ParsedPreferences | null
sharedLists: ParsedSharedList[]
barItems: ParsedBarItem[]
}
export interface RestoreSummary {
@@ -120,6 +134,7 @@ export interface RestoreSummary {
wishlist: { created: number; updated: number; skipped: number }
preferences: { restored: boolean }
sharedLists: { created: number; updated: number; skipped: number }
barItems: { created: number; updated: number; skipped: number }
}
// ─── Export ─────────────────────────────────────────────────────
@@ -129,7 +144,8 @@ export function generateBackupCsv(
ratings: (Rating & { drink: { name: string } })[],
wishlistItems: WishlistItem[],
preferences: UserPreference | null,
sharedLists: SharedList[]
sharedLists: SharedList[],
barItems: BarItem[] = []
): string {
const rows: Record<string, string>[] = []
@@ -212,6 +228,20 @@ export function generateBackupCsv(
})
}
// Bar items
for (const b of barItems) {
rows.push({
_type: "bar_item",
_originalId: b.id,
name: b.name,
category: b.category,
quantity: b.quantity,
notes: b.notes ?? "",
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
})
}
return objectsToCsv(CSV_HEADERS, rows)
}
@@ -243,6 +273,7 @@ export function parseBackupRows(
wishlistItems: [],
preferences: null,
sharedLists: [],
barItems: [],
}
for (const row of rows) {
@@ -341,6 +372,18 @@ export function parseBackupRows(
})
break
case "bar_item":
data.barItems.push({
_originalId: row._originalId ?? "",
name: row.name ?? "",
category: row.category ?? "SPIRITS",
quantity: row.quantity ?? "FULL",
notes: row.notes || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
// Skip unknown row types
}
}
@@ -390,6 +433,29 @@ export function validateBackupData(
if (!s.title) errors.push(`Shared list row ${i + 1}: title is required`)
}
const VALID_BAR_CATEGORIES = [
"SPIRITS",
"LIQUEURS",
"MIXERS",
"BITTERS",
"GARNISHES",
"TOOLS",
]
const VALID_BAR_QUANTITIES = ["FULL", "HALF", "LOW", "EMPTY"]
for (let i = 0; i < data.barItems.length; i++) {
const b = data.barItems[i]
if (!b.name) errors.push(`Bar item row ${i + 1}: name is required`)
if (!VALID_BAR_CATEGORIES.includes(b.category))
errors.push(
`Bar item row ${i + 1}: invalid category "${b.category}". Expected one of: ${VALID_BAR_CATEGORIES.join(", ")}`
)
if (!VALID_BAR_QUANTITIES.includes(b.quantity))
errors.push(
`Bar item row ${i + 1}: invalid quantity "${b.quantity}". Expected one of: ${VALID_BAR_QUANTITIES.join(", ")}`
)
}
return { valid: errors.length === 0, errors }
}
@@ -397,6 +463,8 @@ export function validateBackupData(
type RestoreMode = "merge-skip" | "merge-update" | "replace"
type DrinkType = "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
type BarItemCategory = "SPIRITS" | "LIQUEURS" | "MIXERS" | "BITTERS" | "GARNISHES" | "TOOLS"
type BarItemQuantity = "FULL" | "HALF" | "LOW" | "EMPTY"
export async function executeRestore(
userId: string,
@@ -411,6 +479,7 @@ export async function executeRestore(
wishlist: { created: 0, updated: 0, skipped: 0 },
preferences: { restored: false },
sharedLists: { created: 0, updated: 0, skipped: 0 },
barItems: { created: 0, updated: 0, skipped: 0 },
}
// STEP 0: If "replace", delete everything first
@@ -420,6 +489,7 @@ export async function executeRestore(
await tx.drink.deleteMany({ where: { userId } })
await tx.wishlistItem.deleteMany({ where: { userId } })
await tx.userPreference.deleteMany({ where: { userId } })
await tx.barItem.deleteMany({ where: { userId } })
}
// STEP 1: Preferences
@@ -723,6 +793,55 @@ export async function executeRestore(
}
}
// STEP 6: Bar items
for (const item of data.barItems) {
if (mode === "replace") {
await tx.barItem.create({
data: {
userId,
name: item.name,
category: item.category as BarItemCategory,
quantity: item.quantity as BarItemQuantity,
notes: item.notes ?? null,
},
})
summary.barItems.created++
} else {
const existing = await tx.barItem.findFirst({
where: {
userId,
name: item.name,
category: item.category as BarItemCategory,
},
})
if (existing) {
if (mode === "merge-update") {
await tx.barItem.update({
where: { id: existing.id },
data: {
quantity: item.quantity as BarItemQuantity,
notes: item.notes ?? null,
},
})
summary.barItems.updated++
} else {
summary.barItems.skipped++
}
} else {
await tx.barItem.create({
data: {
userId,
name: item.name,
category: item.category as BarItemCategory,
quantity: item.quantity as BarItemQuantity,
notes: item.notes ?? null,
},
})
summary.barItems.created++
}
}
}
return summary
},
{ timeout: 60000 }

View File

@@ -71,3 +71,30 @@ export type UserPreferenceInput = z.infer<typeof userPreferenceSchema>
export type WishlistCreate = z.infer<typeof wishlistCreateSchema>
export type SharedListCreate = z.infer<typeof sharedListCreateSchema>
export type SharedListUpdate = z.infer<typeof sharedListUpdateSchema>
export const barItemCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
category: z.enum(["SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", "TOOLS"]),
quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"),
notes: z.string().max(2000).optional(),
})
export const barItemUpdateSchema = barItemCreateSchema.partial()
export type BarItemCreate = z.infer<typeof barItemCreateSchema>
export type BarItemUpdate = z.infer<typeof barItemUpdateSchema>
export const recipeCreateSchema = z.object({
title: z.string().min(1).max(200),
ingredients: z.array(z.object({
name: z.string(),
amount: z.string(),
available: z.boolean(),
})),
steps: z.array(z.string()),
garnish: z.string().max(200).optional().nullable(),
glassware: z.string().max(200).optional().nullable(),
sourceDrinkId: z.string().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
})
export type RecipeCreate = z.infer<typeof recipeCreateSchema>