The idea
We could have built anything for the final project, and we chose to build something for the island we actually live on: one place to find the concerts, exhibitions, festivals and markets happening across Tenerife, instead of hunting through a different page for each one.
It also meant the project had a real shape to it. Events have dates, prices, categories and locations, and every one of those turns into a decision about how you store it, how you filter it and how you show it. That was more interesting than inventing a problem to solve.
Search in plain Spanish, without letting the model invent events
You can ask the catalogue for things the way you would actually say them. Algo gratis este finde. Teatro barato en La Laguna. It reads the question, works out what you meant by the dates and the price and the mood, and comes back with real events and a line explaining why it picked them.
It works in three steps. First the API pulls the events that are actually eligible, active and happening between today and 120 days out, so the model is never reasoning about a stale catalogue. Then it builds a prompt that lists those events one per line with their id, title, date, weekday, category, location and price, and states today's date and weekday in Spanish so that este finde resolves to the right weekend. Gemini is asked for strict JSON, up to four event ids in order of relevance plus one sentence of reasoning, with the response constrained to application/json and the temperature down at 0.3 so it stays literal.
The decision I care about is what comes back. Gemini only returns ids and a sentence. It never returns event data. Those ids are then intersected with the candidate list and the events are read back out of the database, so what reaches the browser is always a real row with a real date and a real location. If the model invents an id, it simply does not match anything and quietly disappears. The model is allowed to choose. It is not allowed to author.
It also fails in a way you can read. No API key configured returns a 503 saying so rather than a stack trace, a response that will not parse as JSON returns a 502 and gets logged with the offending text, and an empty catalogue never calls the API at all.
Auth was the part worth getting right
Most student projects put a JWT in localStorage and call it done. That token cannot be revoked, and anything that can read the page can read the token, so I wanted a design where losing one piece does not hand over the account.
Access tokens are short lived, fifteen minutes. Refresh tokens last seven days, or thirty if you tick remember me. The refresh token is never stored as it was issued: the database keeps a SHA-256 hash of it, so someone reading the tokens table still cannot authenticate as anybody. Using a refresh token rotates it, meaning the old one is marked revoked in the same operation that issues its replacement. Logging out revokes it explicitly.
Filtering without a pile of if statements
The catalogue filters by category, free text, date range and price range, in any combination. Writing that as branching query methods gets ugly fast, so the API composes JPA Specifications instead: one small specification per filter, combined with and, and null ones simply skipped.
One of those is always applied and is not optional. noFinalizado restricts results to events happening today or later, because a cultural events site that shows you last month's concerts is not doing its job. That decision comes back later in a way I did not expect.
The demo broke, and the cause was two bugs stacked
Months after handing the project in, the live demo went empty. The page loaded, the filters rendered, and there were zero events.
The first cause was easy: the free Postgres instance had expired and been deleted, so the backend could not start. I moved the database to a provider whose free tier does not expire, set the connection details, and the API came back.
It still only returned eight events out of seventy seven. The second cause was hiding behind the first. The seeder anchored its dates to a hardcoded date in May, and combined with noFinalizado filtering out anything before today, almost every seeded event had quietly aged into the past. Restoring the database alone would never have fixed it. Seeding relative to today did.
Making the demo survive its own hosting
Fixing it once was not enough, because free hosting sleeps. The backend spins down after fifteen minutes of inactivity and takes the better part of a minute to wake, and nobody waits a minute at an empty page.
So I did to the frontend what I had already done in SkyAbove: when the API cannot be reached, serve the last known good data instead of nothing. The seeded catalogue is snapshotted into the frontend, with dates stored as day offsets and resolved against today so the cached events never age out the way the original ones did. The offline path repeats the same filtering the backend does, so the cached catalogue behaves like the live one. Reads give up after eight seconds and fall back. Writes still fail loudly, because quietly pretending a save worked would be a much worse lie than showing slightly old data.
The page says so when it is serving the snapshot. Showing cached data as though it were live would be the kind of small dishonesty I was trying to design out in the first place.
What’s next
Schema changes still run through Hibernate's ddl-auto, which is fine for coursework and wrong for anything real, so a proper migration tool is the first thing I would add. I would also like saved events to survive more than they currently do, and to keep building on the search rather than leaving it where the deadline left it.
The bigger thing I want is real data. Right now the catalogue is seeded, and the version of this I would actually use pulls from the places these events are really announced.
