feat: dish recognition job context, diary linkage, home widget, history page

Backend:
- Rename recognition_jobs → dish_recognition_jobs; add target_date and
  target_meal_type columns to capture scan context at submission time
- Add job_id FK on meal_diary so entries are linked to their origin job
- New GET /ai/jobs endpoint returns today's unlinked jobs for the current user
- diary.Entry and CreateRequest gain job_id field; repository reads/writes it
- CORS middleware: allow Accept-Language and Cache-Control headers
- Logging middleware: implement http.Flusher on responseWriter (needed for SSE)
- Consolidate migrations into a single 001_initial_schema.sql

Flutter:
- POST /ai/recognize-dish now sends target_date and target_meal_type
- DishResultSheet accepts jobId; _addToDiary includes it in the diary payload,
  saves last-used meal type to SharedPreferences, invalidates todayJobsProvider
- TodayJobsNotifier + todayJobsProvider: loads unlinked jobs via GET /ai/jobs
- Home screen shows _TodayJobsWidget (up to 3 tiles) between macros and meals;
  tapping a done tile reopens DishResultSheet with the stored result
- Quick Actions row: third button "История" → /scan/history
- New RecognitionHistoryScreen: full-screen list of today's unlinked jobs
- LocalPreferences wrapper over SharedPreferences (last_used_meal_type)
- app_theme: apply Google Fonts Roboto as default font family

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-19 16:11:21 +02:00
parent 1aaf20619d
commit cf69a4a3d9
21 changed files with 682 additions and 113 deletions

View File

@@ -17,6 +17,7 @@ type Entry struct {
DishID string `json:"dish_id"`
RecipeID *string `json:"recipe_id,omitempty"`
PortionG *float64 `json:"portion_g,omitempty"`
JobID *string `json:"job_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -30,4 +31,5 @@ type CreateRequest struct {
DishID *string `json:"dish_id"`
RecipeID *string `json:"recipe_id"`
PortionG *float64 `json:"portion_g"`
JobID *string `json:"job_id"`
}

View File

@@ -30,7 +30,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
rows, err := r.pool.Query(ctx, `
SELECT
md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at,
COALESCE(dt.name, d.name) AS dish_name,
r.calories_per_serving * md.portions,
r.protein_per_serving * md.portions,
@@ -72,10 +72,10 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
var entryID string
insertError := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g)
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8)
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g, job_id)
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG,
userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, req.JobID,
).Scan(&entryID)
if insertError != nil {
return nil, fmt.Errorf("insert diary entry: %w", insertError)
@@ -84,7 +84,7 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
row := r.pool.QueryRow(ctx, `
SELECT
md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at,
COALESCE(dt.name, d.name) AS dish_name,
r.calories_per_serving * md.portions,
r.protein_per_serving * md.portions,
@@ -121,7 +121,7 @@ func scanEntry(s scannable) (*Entry, error) {
var entry Entry
scanError := s.Scan(
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.CreatedAt,
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt,
&entry.Name,
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
)

View File

@@ -84,6 +84,14 @@ type imageRequest struct {
MimeType string `json:"mime_type"`
}
// recognizeDishRequest is the body for POST /ai/recognize-dish.
type recognizeDishRequest struct {
ImageBase64 string `json:"image_base64"`
MimeType string `json:"mime_type"`
TargetDate *string `json:"target_date"`
TargetMealType *string `json:"target_meal_type"`
}
// imagesRequest is the request body for multi-image endpoints.
type imagesRequest struct {
Images []imageRequest `json:"images"`
@@ -173,9 +181,9 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
// RecognizeDish handles POST /ai/recognize-dish (async).
// Enqueues the image for AI processing and returns 202 Accepted with a job_id.
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
// Body: {"image_base64": "...", "mime_type": "image/jpeg", "target_date": "2006-01-02", "target_meal_type": "lunch"}
func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) {
var req imageRequest
var req recognizeDishRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required")
return
@@ -186,11 +194,13 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
lang := locale.FromContext(request.Context())
job := &Job{
UserID: userID,
UserPlan: userPlan,
ImageBase64: req.ImageBase64,
MimeType: req.MimeType,
Lang: lang,
UserID: userID,
UserPlan: userPlan,
ImageBase64: req.ImageBase64,
MimeType: req.MimeType,
Lang: lang,
TargetDate: req.TargetDate,
TargetMealType: req.TargetMealType,
}
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
slog.Error("insert recognition job", "err", insertError)
@@ -221,6 +231,24 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
})
}
// ListTodayJobs handles GET /ai/jobs — returns today's unlinked jobs for the current user.
func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID)
if listError != nil {
slog.Error("list today unlinked jobs", "err", listError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs")
return
}
// Return an empty array instead of null when there are no results.
if summaries == nil {
summaries = []*JobSummary{}
}
writeJSON(responseWriter, http.StatusOK, summaries)
}
// GetJobStream handles GET /ai/jobs/{id}/stream — SSE endpoint for job updates.
func (handler *Handler) GetJobStream(responseWriter http.ResponseWriter, request *http.Request) {
handler.sseBroker.ServeSSE(responseWriter, request)

View File

@@ -20,18 +20,31 @@ const (
TopicFree = "ai.recognize.free"
)
// Job represents an async dish recognition task stored in recognition_jobs.
// Job represents an async dish recognition task stored in dish_recognition_jobs.
type Job struct {
ID string
UserID string
UserPlan string
ImageBase64 string
MimeType string
Lang string
Status string
Result *ai.DishResult
Error *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
ID string
UserID string
UserPlan string
ImageBase64 string
MimeType string
Lang string
TargetDate *string // nullable YYYY-MM-DD
TargetMealType *string // nullable e.g. "lunch"
Status string
Result *ai.DishResult
Error *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
}
// JobSummary is a lightweight job record for list endpoints (omits ImageBase64).
type JobSummary struct {
ID string `json:"id"`
Status string `json:"status"`
TargetDate *string `json:"target_date,omitempty"`
TargetMealType *string `json:"target_meal_type,omitempty"`
Result *ai.DishResult `json:"result,omitempty"`
Error *string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -9,13 +9,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// JobRepository provides all DB operations on recognition_jobs.
// JobRepository provides all DB operations on dish_recognition_jobs.
type JobRepository interface {
InsertJob(ctx context.Context, job *Job) error
GetJobByID(ctx context.Context, jobID string) (*Job, error)
UpdateJobStatus(ctx context.Context, jobID, status string, result *ai.DishResult, errMsg *string) error
QueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error)
NotifyJobUpdate(ctx context.Context, jobID string) error
ListTodayUnlinked(ctx context.Context, userID string) ([]*JobSummary, error)
}
// PostgresJobRepository implements JobRepository using a pgxpool.
@@ -31,10 +32,10 @@ func NewJobRepository(pool *pgxpool.Pool) *PostgresJobRepository {
// InsertJob inserts a new recognition job and populates the ID and CreatedAt fields.
func (repository *PostgresJobRepository) InsertJob(queryContext context.Context, job *Job) error {
return repository.pool.QueryRow(queryContext,
`INSERT INTO recognition_jobs (user_id, user_plan, image_base64, mime_type, lang)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO dish_recognition_jobs (user_id, user_plan, image_base64, mime_type, lang, target_date, target_meal_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at`,
job.UserID, job.UserPlan, job.ImageBase64, job.MimeType, job.Lang,
job.UserID, job.UserPlan, job.ImageBase64, job.MimeType, job.Lang, job.TargetDate, job.TargetMealType,
).Scan(&job.ID, &job.CreatedAt)
}
@@ -44,13 +45,15 @@ func (repository *PostgresJobRepository) GetJobByID(queryContext context.Context
var resultJSON []byte
queryError := repository.pool.QueryRow(queryContext,
`SELECT id, user_id, user_plan, image_base64, mime_type, lang, status,
`SELECT id, user_id, user_plan, image_base64, mime_type, lang,
target_date::text, target_meal_type, status,
result, error, created_at, started_at, completed_at
FROM recognition_jobs WHERE id = $1`,
FROM dish_recognition_jobs WHERE id = $1`,
jobID,
).Scan(
&job.ID, &job.UserID, &job.UserPlan,
&job.ImageBase64, &job.MimeType, &job.Lang, &job.Status,
&job.ImageBase64, &job.MimeType, &job.Lang,
&job.TargetDate, &job.TargetMealType, &job.Status,
&resultJSON, &job.Error, &job.CreatedAt, &job.StartedAt, &job.CompletedAt,
)
if queryError != nil {
@@ -86,13 +89,13 @@ func (repository *PostgresJobRepository) UpdateJobStatus(
switch status {
case JobStatusProcessing:
_, updateError := repository.pool.Exec(queryContext,
`UPDATE recognition_jobs SET status = $1, started_at = now() WHERE id = $2`,
`UPDATE dish_recognition_jobs SET status = $1, started_at = now() WHERE id = $2`,
status, jobID,
)
return updateError
default:
_, updateError := repository.pool.Exec(queryContext,
`UPDATE recognition_jobs
`UPDATE dish_recognition_jobs
SET status = $1, result = $2, error = $3, completed_at = now()
WHERE id = $4`,
status, resultJSON, errMsg, jobID,
@@ -109,7 +112,7 @@ func (repository *PostgresJobRepository) QueuePosition(
) (int, error) {
var position int
queryError := repository.pool.QueryRow(queryContext,
`SELECT COUNT(*) FROM recognition_jobs
`SELECT COUNT(*) FROM dish_recognition_jobs
WHERE status IN ('pending', 'processing')
AND user_plan = $1
AND created_at < $2`,
@@ -123,3 +126,48 @@ func (repository *PostgresJobRepository) NotifyJobUpdate(queryContext context.Co
_, notifyError := repository.pool.Exec(queryContext, `SELECT pg_notify('job_update', $1)`, jobID)
return notifyError
}
// ListTodayUnlinked returns today's jobs for the given user that have not yet been
// linked to any meal_diary entry.
func (repository *PostgresJobRepository) ListTodayUnlinked(queryContext context.Context, userID string) ([]*JobSummary, error) {
rows, queryError := repository.pool.Query(queryContext,
`SELECT id, status, target_date::text, target_meal_type,
result, error, created_at
FROM dish_recognition_jobs
WHERE user_id = $1
AND created_at::date = CURRENT_DATE
AND id NOT IN (
SELECT job_id FROM meal_diary WHERE job_id IS NOT NULL
)
ORDER BY created_at DESC`,
userID,
)
if queryError != nil {
return nil, queryError
}
defer rows.Close()
var summaries []*JobSummary
for rows.Next() {
var summary JobSummary
var resultJSON []byte
scanError := rows.Scan(
&summary.ID, &summary.Status, &summary.TargetDate, &summary.TargetMealType,
&resultJSON, &summary.Error, &summary.CreatedAt,
)
if scanError != nil {
return nil, scanError
}
if resultJSON != nil {
var dishResult ai.DishResult
if unmarshalError := json.Unmarshal(resultJSON, &dishResult); unmarshalError == nil {
summary.Result = &dishResult
}
}
summaries = append(summaries, &summary)
}
if rowsError := rows.Err(); rowsError != nil {
return nil, rowsError
}
return summaries, nil
}

View File

@@ -10,7 +10,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID", "Accept-Language", "Cache-Control"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 300,

View File

@@ -16,6 +16,12 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Flush() {
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()

View File

@@ -120,6 +120,7 @@ func NewRouter(
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
r.Get("/jobs", recognitionHandler.ListTodayJobs)
r.Get("/jobs/{id}", recognitionHandler.GetJob)
r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream)
r.Post("/generate-menu", menuHandler.GenerateMenu)

View File

@@ -390,6 +390,31 @@ CREATE TABLE shopping_lists (
UNIQUE (user_id, menu_plan_id)
);
-- ---------------------------------------------------------------------------
-- dish_recognition_jobs
-- ---------------------------------------------------------------------------
CREATE TABLE dish_recognition_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL,
image_base64 TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
lang TEXT NOT NULL DEFAULT 'en',
target_date DATE,
target_meal_type TEXT,
status TEXT NOT NULL DEFAULT 'pending',
-- pending | processing | done | failed
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_dish_recognition_jobs_user
ON dish_recognition_jobs (user_id, created_at DESC);
CREATE INDEX idx_dish_recognition_jobs_pending
ON dish_recognition_jobs (status, user_plan, created_at ASC);
-- ---------------------------------------------------------------------------
-- meal_diary
-- ---------------------------------------------------------------------------
@@ -400,33 +425,14 @@ CREATE TABLE meal_diary (
meal_type TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual',
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
portion_g DECIMAL(10,2),
job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
-- ---------------------------------------------------------------------------
-- recognition_jobs
-- ---------------------------------------------------------------------------
CREATE TABLE recognition_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL,
image_base64 TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
lang TEXT NOT NULL DEFAULT 'en',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | processing | done | failed
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_recognition_jobs_user ON recognition_jobs (user_id, created_at DESC);
CREATE INDEX idx_recognition_jobs_pending ON recognition_jobs (status, user_plan, created_at ASC);
CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL;
-- ---------------------------------------------------------------------------
-- Seed data: languages
@@ -605,8 +611,8 @@ INSERT INTO dish_category_translations (category_slug, lang, name) VALUES
('snack', 'ru', 'Снэк');
-- +goose Down
DROP TABLE IF EXISTS recognition_jobs;
DROP TABLE IF EXISTS meal_diary;
DROP TABLE IF EXISTS dish_recognition_jobs;
DROP TABLE IF EXISTS shopping_lists;
DROP TABLE IF EXISTS menu_items;
DROP TABLE IF EXISTS menu_plans;