steve07s 1 年之前
当前提交
1f60e2d614
共有 11 个文件被更改,包括 984 次插入0 次删除
  1. 3 0
      .gitignore
  2. 166 0
      README.md
  3. 二进制
      bun.lockb
  4. 25 0
      package.json
  5. 151 0
      pass_db.js
  6. 17 0
      routers/api.js
  7. 19 0
      server.js
  8. 47 0
      static/index.html
  9. 112 0
      static/js/api_calls.js
  10. 247 0
      static/js/frontend.js
  11. 197 0
      static/styles/styles.css

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+node_modules/
+**/*solution*
+.env

+ 166 - 0
README.md

@@ -0,0 +1,166 @@
+# Assignment #4 - Jicnic
+
+## Introduction
+
+In this Assignment, you will demonstrate your ability to combined front-end-and-back-end app, using the skills from all three units, including AJAX.  You will make a simple app that could be used to plan an event with other people, such as a picnic, by collecting votes from different people about what days they are available.
+
+You may use fetch, or axios, or an alternate equivalent AJAX library.  You may not use any heavyweight libraries that solve the primary challenges of the Assignment, at least not without prior permission.  If in doubt, ask.
+
+This Assignment is worth 25% of your final grade.
+
+
+## Due Date
+
+This Assignment is due on Saturday, April 13th, at 11:59pm.
+
+## Submission
+
+GitHub Classroom, same as before.  If you changed GitHub username, be sure to tell me, same as before.
+
+
+## Intellectual Honesty
+
+
+All the same notes apply as on previous assignments.
+
+This assignment is **individual work**.  You may use tools that are not made of people, like textbooks, stack overflow, AIs unless they are secretly fake AIs where a human being is actually answering the chat, whatever.  You may not ask others for help (except me), you may not look at the code of other students, you may not help other students, you may not show other students your code.
+
+SOURCES.md is still a good idea, though still not strictly required.
+
+
+
+
+## Functional Requirements
+
+### PASS Tier
+
+* Statement To Grader
+    * As with previous work, please include a file called `STATEMENT_TO_GRADER.md`.
+    * As with previous work, this file should start with "I believe I have completed 100% of the requirements for the ____ tier".
+    * As with previous work, feel free to add anything else that you would like me to know as I grade your work.
+* Get the signup/login/logout working, via AJAX
+    * implement the backend routes, at
+        * `POST /api/v1/signup`
+            * don't forget that signup should do a login
+        * `POST /api/v1/login`
+        * `POST /api/v1/logout`
+        * `GET /api/v1/getSession`
+    * any data that needs sending, send it in JSON in the body of the request
+    * if the request is successful, return a status 200, and a JSON like one of the following:
+        * `{ "success": true, "data": { "username": USERNAME } }`
+        * `{ "success": true, "data": null }`
+    * if the request is invalid, set an appropriate HTTP status, and return JSON like this:
+        * `{ "success": false, "error": "This is a string explaining what the user messed up"`
+    * on the frontend, wire the user interactions up to some kind of AJAX call
+    * also connect up the responses
+        * what should happen on success?  figure it out.
+        * on failure, you may cut a corner here and simply `alert` the error message
+            * remember, `alert` is not okay for real webapps, it makes you look like a filthy amateur, I'm just cutting you a break here
+            * OPTIONAL: the good thing to do with the errors is add a div below the login form to display any errors
+                * if you do this, be sure to erase the error when the user tries again
+* Get vote-retrieval working
+    * implement the backend routes, something like
+        * `GET /api/v1/votes/list`
+            * you can change this if you want, if you're prepared to explain why you changed it
+        * your return format should still be like:
+            * `{ "success": true, "data": ????? }`
+    * on the frontend, trigger this AJAX call on pageload, and use the response to fill out the display of existing votes
+    * the frontend should show any number of days that the backend sends it, whether that's 3 or 30
+        * so that means the backend must control how many days!  (though any number is fine)
+    * note that my fake frontend sample code, and my DB, store the votes in different formats.  this is deliberate.
+        * your backend code must convert from one format to the other, because that's what backends do
+* Get the weather working
+    * make calls to a weather API, for example https://openweathermap.org/api/
+        * in particular, we want a weather forecast for BCIT's location
+            * you MAY hardcode the location into your frontend code at the pass tier
+    * figure out what you can get the API to do, this is a puzzle that you need to solve
+        * I used the free tier, without giving them my credit card
+            * it was a bit of a fight to get the free tier to do what I want
+            * actually the solution I came up with only works for very specific dates
+        * if you're willing to give them your credit card, you can get better results, still for free
+            * but that's too much work for an assignment, IMO
+        * you could also apply for https://docs.openweather.co.uk/our-initiatives/student-initiative
+            * still too much work for an assignment, but if you're going to use an API in a project, look for things like this
+    * each day should render two things:
+        * a predicted temperature for the day
+            * use the predicted high temp for the day, or if you want use any other temperature, I don't really care
+        * an icon summarizing the day's weather
+        * mouseover text (i.e. `title`) that explains the icon
+    * make the calls to the openweathermap API from the frontend
+        * there are pros and cons to this, but I want you to have experience making 3rd-party API calls from the frontend
+        * you do need to get your API key to the frontend, and at the PASS tier you may hardcode it in the frontend
+            * if you do hardcode it, then make sure that you never make your repository public, or people will steal your key
+                * if you haven't added your credit card, this isn't a huge deal, but it's still bad citizenship
+            * the alternative to hardcoding it is to use the `.env`, with notes about that in the SATIS tier
+* for all AJAX calls, you may use naked XHR, or fetch, or axios, or jQuery, or any other similar library
+    * my suggestion is using fetch or axios, I think those are more competent-looking if you're showing someone your code
+* for this tier, I believe that you should be able to do the assignment by mostly only editing around four files
+    * on the frontend, `/static/js/api_calls.js`, quite a lot of work in here
+    * also on the frontend, `/static/js/frontend.js` is where you would do the work for rendering the weather forecast data
+    * on the backend, `/routers/api.js` for all your routes, quite a lot of work in here
+    * also on the backend, `server.js` for middleware configuration
+        * the template project doesn't handle the `.env file`
+        * it's also missing a middleware that you'll need
+        * you could in theory do both of those things in `api_calls.js`, but `server.js` is the right place
+    * maybe I forgot some other files
+    * you may edit other files if you choose
+* as usual, I expect some reasonable overall code quality, appropriate to your career goals
+
+
+
+### SATIS Tier
+
+* Make voting work
+    * if the user is logged in, they should see voting buttons for every day
+        * they can vote yes, they can vote no, they can unset their vote
+    * if they've already voted, make their own vote look a little different in the list
+        * maybe a different border, or a different background opacity, or something
+    * probably you should make an endpoint roughly like `POST /api/v1/votes/set`
+        * you already know that any self-respecting dev would secure this on the backend, so I don't know why I wrote this
+* Soft refresh
+    * add a button to the header that says "Refresh" or "🔄" or something
+    * when this button is clicked, do another AJAX call to get all the votes again, and re-render the DOM
+* use a `.env` file, and the `dotenv` library, for configuration
+    * BCIT's lat and long should be in this file
+    * also your openweathermap.org secret key
+    * remember that you should commit a suitable `.env.example`, and you **should not commit** your `.env`
+* API key security
+    * it's actually kinda questionable sending our openweathermap API token to the frontend
+        * because then any user of ours could steal our key and use it for their own app
+    * we're not going to actually fix the security hole, but we're going to pretend, as following
+    * every time you want to make an API call to the openweathermap API, you must first make an API call to your own backend to get the secret key, and then forget the key afterward
+        * this doesn't actually help
+        * but, if the weather API were fancier, we could request single-use tokens, and that'd be more secure
+        * and if we were doing that, the frontend would work like this
+        * so you'll need to add a backend route like `GET /api/v1/getToken/openweathermap`, that returns the openweathermap API key
+        * that route should only return the real key if the user is logged in
+            * so if the user is not logged in, they won't get working weather.  make this unobtrusive.
+            * so if they WERE logged out, and they log in, don't forget to fix up the weather data.
+* no hardcoding the lat/long
+    * just as you're asking the backend for the API key, you should also ask the backend for the lat/long
+    * add `GET /api/v1/getLocation`
+    * gotta call this before you do a weather call
+
+
+
+### EXEMP Tier
+
+* add websockets
+    * one small problem is that if I open the voting app on my computer, and someone else votes on their computer, I won't see their vote until I refresh the page
+    * add websockets to fix this
+    * when the frontend gets a message that someone changed their vote, trigger the soft-refresh functionality from SATIS
+        * that is, the websockets are being used to trigger AJAX
+            * this is arguably redundant, but it's easier for you to code and easier for me to read that you did the right thing
+    * I recommend using naked websockets on the frontend, and a simple library like `ws` on the backend
+        * but if you want to use other libraries, that's fine
+    * https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
+* API rate limiting
+    * add some code so that your frontend doesn't ask the API for the weather for a given day more often than once per (INTERVAL)
+        * probably set the interval to 10 seconds, but it should be easy to change to a larger time if we wanted to
+    * this is called caching
+    * maybe consider putting the cache in localStorage so it'll work even if you refresh the page
+* OPTIONAL: also if you're feeling fancy, geolocate the weather forecast
+    * use the geolocation API to determine where the user is, for the lat and long you'll send to the weather API
+    * note that this actually makes no sense, because what matters is the weather where the picnic is, not the weather where the user is, but just pretend okay
+    * https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API
+

二进制
bun.lockb


+ 25 - 0
package.json

@@ -0,0 +1,25 @@
+{
+  "name": "a4_jicnic",
+  "version": "0.1.0",
+  "description": "",
+  "main": "server.js",
+  "scripts": {
+    "browser-sync": "browser-sync start --proxy 'localhost:8000' --files 'static/**/*.*'",
+    "dev": "bun run server.js --watch"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "UNLICENSED",
+  "dependencies": {
+    "body-parser": "^1.20.2",
+    "browser-sync": "^3.0.2",
+    "cookie-session": "^2.0.0",
+    "date-fns": "^3.6.0",
+    "dotenv": "^16.0.3",
+    "ejs": "^3.1.9",
+    "express": "^4.18.2"
+  },
+  "devDependencies": {
+    "nodemon": "^3.1.0"
+  }
+}

+ 151 - 0
pass_db.js

@@ -0,0 +1,151 @@
+const date_fns = require("date-fns");
+
+
+let users = {}
+let votes = {};
+
+
+
+
+
+function users_init_fixed() {
+  users = {
+    'fry': {
+      username: 'fry',
+      password: 'f'
+    },
+    'leela': {
+      username: 'leela',
+      password: 'l'
+    },
+    'bender': {
+      username: 'bender',
+      password: 'b'
+    },
+    'farnsworth': {
+      username: 'farnsworth',
+      password: 'f'
+    },
+  }
+}
+
+function vote_init_fixed() {
+  votes = {
+    "2024-04-01": {
+      "fry": "no",
+      "leela": "no",
+      "bender": "no",
+      "farnsworth": "yes"
+    },
+    "2024-04-02": {},
+    "2024-04-03": {
+      "fry": "yes"
+    },
+    "2024-04-04": {
+      "fry": "yes",
+      "bender": "yes",
+      "farnsworth": "no"
+    },
+  }
+}
+
+function vote_init_empty() {
+  votes = {};
+}
+
+function vote_init_random(num_days) {
+  let today = new Date();
+  console.log("seeidng a bunch of false votes for", today);
+  for (let i = 0; i < num_days; i++) {
+    let d = date_fns.add(today, { days: i });
+    let d_str = date_fns.format(d, "yyyy-MM-dd")
+    if (votes[d_str] === undefined) {
+      votes[d_str] = {}
+    }
+    for (let user in users) {
+      let valence = Math.random();
+      if (valence < 0.25) {
+        placeVote(user, d_str, "no");
+      } else if (valence > 0.75) {
+        placeVote(user, d_str, "yes");
+      }
+    }
+  }
+  console.log(JSON.stringify(votes, null, 2))
+}
+
+
+
+
+
+
+function isUsernameTaken(username) {
+  return !!users[username];
+}
+
+function createUser(username, password) {
+  if (!isUsernameTaken(username)) {
+    users[username] = {
+      username,
+      password,
+    }
+    return true;
+  } else {
+    return false;
+  }
+}
+
+function authenticateUser(username, password) {
+  if (users[username]) {
+    let user = users[username];
+    if (user.password === password) {
+      return { username: user.username }
+    }
+  } else {
+    return null;
+  }
+}
+
+
+
+
+
+
+function getVotesAllDays() {
+  return JSON.parse(JSON.stringify(votes))
+}
+
+
+function getVotesOneDay(date) {
+  return JSON.parse(JSON.stringify(votes[date]))
+}
+
+function placeVote(username, date, vote) {
+  if (votes[date] === undefined) {
+    votes[date] = {};
+  }
+  if (!vote || vote === "maybe") {
+    delete votes[date][username];
+  } else {
+    votes[date][username] = vote;
+  }
+}
+
+
+
+
+module.exports = {
+  // these are the actual useful methods
+  isUsernameTaken,
+  createUser,
+  authenticateUser,
+  getVotesAllDays,
+  getVotesOneDay,
+  placeVote,
+
+  // I thought I'd try organizing my seed-data like this instead, is this helpful?
+  users_init_fixed,
+  vote_init_empty,
+  vote_init_fixed,
+  vote_init_random,
+}

+ 17 - 0
routers/api.js

@@ -0,0 +1,17 @@
+let express = require("express");
+let db = require("../pass_db");
+
+const router = express.Router();
+
+
+
+router.get("/test", (req, res) => {
+  res.send("tested")
+})
+
+
+
+module.exports = router;
+
+
+

+ 19 - 0
server.js

@@ -0,0 +1,19 @@
+const express = require("express");
+const bodyParser = require("body-parser")
+const cookieSession = require("cookie-session");
+
+const db = require('./pass_db.js');
+db.users_init_fixed()
+db.vote_init_random(5);     // you COULD change this, but you could also leave it alone?
+
+const app = express();
+
+const PORT = 8000;
+app.use(express.static("static"))
+app.set('view engine', 'ejs')
+app.use(cookieSession({ name: 'session', keys: ['every_student_will_change_this_key_because_it_would_be_embarrassing_to_leave_it'] }))
+
+const apiRouter = require("./routers/api")
+app.use('/api/v1', apiRouter);
+
+app.listen(PORT, () => console.log(`server should be running at http://localhost:${PORT}/`))

+ 47 - 0
static/index.html

@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Jicnic - Jlan your next picnic!</title>
+    <link rel="stylesheet" href="/styles/styles.css" />
+    <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+    <script src="https://unpkg.com/axios/dist/axios.js"></script>
+    <script defer src="/js/api_calls.js"></script>
+    <script defer src="/js/frontend.js"></script>
+  </head>
+
+  <body>
+    <header>
+      <div class="headerleft">
+        <a href="/" class="logo">
+          <span class="logo">Jicnic</span>
+        </a>
+      </div>
+      <div class="headermiddle"></div>
+
+      <div class="headerright">
+        <form class="authform">
+          <div class="forloggedin">
+            <span>Logged in as </span><span class="username">user</span>
+            <button data-auth-action="logout" class="logout">Log Out</button>
+          </div>
+          <div class="forloggedout">
+            <label for="headerusername">username:</label>
+            <input id="headerusername" name="username" />
+            <label for="headerpassword">password:</label>
+            <input id="headerpassword" name="password" input type="password" />
+            <button data-auth-action="signup" class="signup">Sign Up</button>
+            <button data-auth-action="login" class="login">Log In</button>
+            <button onclick="ajaxLogin()">Log In TEST</button>
+          </div>
+          <div class="error-message" style="color: rgb(80, 48, 48)"></div>
+        </form>
+      </div>
+    </header>
+    <main>
+      <section class="days-view"></section>
+    </main>
+  </body>
+</html>

+ 112 - 0
static/js/api_calls.js

@@ -0,0 +1,112 @@
+let fake_local_session = { username: 'nobody' };
+
+let fake_local_votesdata = [
+  {
+    date: "2024-04-01",
+    votes: {
+      'who': "yes",
+      'what': "no",
+    },
+  },
+  {
+    date: "2024-04-02",
+    votes: {
+      'what': "no",
+      'when': "yes",
+      'where': "yes",
+    },
+  },
+  {
+    date: "2024-04-03",
+    votes: {
+      'how': "no",
+      'random': Math.random() < 0.5 ? "no" : "yes",
+    },
+  },
+]
+
+
+//////  This line, and everything above this line, should be deleted when you get the AJAX working.  Really.
+
+
+
+
+
+
+async function getSessionFromBackend() {
+  try {
+    const response = await axios.get('/api/v1/getSession');
+    return response.data;
+  } catch (error) {
+    console.error('Error fetching session from backend:', error);
+    return { success: false, data: null, error: error.toString() };
+  }
+}
+
+async function getVotesFromBackend() {
+  try {
+    const response = await axios.get('/api/v1/votes');
+    return response.data;
+  } catch (error) {
+    console.error('Error fetching votes from backend:', error);
+    return { success: false, data: null, error: error.toString() };
+  }
+}
+
+
+
+
+
+
+async function setMyVote(day, vote) {
+  // this is a placeholder.  rewrite it completely.  (you can ignore this for Pass tier)
+  if (!fake_local_session) return false;
+  let me = fake_local_session.username;
+  let dayofvotes = fake_local_votesdata.filter(vd => vd.date === day)[0];
+  if (!me || !dayofvotes) return false;
+  if (vote === 'maybe') {
+    delete dayofvotes.votes[me];
+  } else {
+    dayofvotes.votes[me] = vote;
+  }
+  return { success: true };
+}
+
+
+
+
+
+
+async function ajaxSignup(username, password) {
+  try {
+    const response = await axios.post('/api/v1/signup', { username, password });
+    return response.data;
+  } catch (error) {
+    console.error('Error signing up:', error);
+    return { success: false, error: error.toString() };
+  }
+}
+
+
+async function ajaxLogin(username, password) {
+  try {
+    const response = await axios.post('/api/v1/login', { username, password });
+    return response.data;
+  } catch (error) {
+    console.error('Error logging in:', error);
+    return { success: false, error: error.toString() };
+  }
+}
+
+async function ajaxLogout() {
+  try {
+    const response = await axios.post('/api/v1/logout');
+    return response.data;
+  } catch (error) {
+    console.error('Error logging out:', error);
+    return { success: false, error: error.toString() };
+  }
+}
+
+
+

+ 247 - 0
static/js/frontend.js

@@ -0,0 +1,247 @@
+function setLoggedIn(loggedInBoolean) {
+  let body = document.body;
+  body.dataset.loggedIn = !!loggedInBoolean;
+}
+
+
+
+
+function createExistingVote(voter, vote) {
+  let voteDiv = document.createElement('div');
+  voteDiv.innerHTML = "";
+  if (vote === "yes") {
+    voteDiv.classList.add('vote-yes');
+  } else if (vote === "no") {
+    voteDiv.classList.add('vote-no');
+  }
+  voteDiv.innerText = voter;
+  return voteDiv;
+}
+
+function createOneDayCard(dayData, currentSession, weather) {
+  let date = new Date(dayData.date + 'T00:00:00');
+  let card = document.createElement('div');
+  card.innerHTML = `
+      <div class="cardtop">
+        <div class="date">
+          <div class="dow">${date.toLocaleString("en-CA", { weekday: 'long' })}</div>
+          <div class="dom">${date.toLocaleString("en-CA", { month: 'short', day: 'numeric' })}</div>
+        </div>
+        <div class="weather">
+          <div class="temp">
+            ?
+          </div>
+          <div class="weath">
+            <img>
+          </div>
+        </div>
+      </div>
+      <div class="make-vote">
+        <div class="satis-tier forloggedin">
+          Can you attend?
+          <div class="">
+            <button class="vote yes" data-vote="yes">
+              Yes ✔️
+            </button>
+            <button class="vote maybe" data-vote="">
+              ??
+            </button>
+            <button class="vote no" data-vote="no">
+              No ❌
+            </button>
+          </div>
+        </div>
+      </div>
+      <div class="existing-votes">
+      </div>
+    `
+  card.classList.add("card")
+  card.dataset.date = dayData.date;
+
+  if (weather) {
+    // you need to figure this part out yourself
+  }
+
+  let existingVotesDiv = card.querySelector('.existing-votes');
+  for (let [voter, vote] of Object.entries(dayData.votes)) {
+    existingVotesDiv.append(createExistingVote(voter, vote))
+  }
+
+  return card;
+}
+
+
+
+function updateVotableDays(daysWithVotes, currentSession, weatherForecasts) {
+  let daysView = document.querySelector(".days-view");
+  if (!daysView) {
+    console.error("could not find element to put days into")
+    return;
+  }
+
+  daysView.innerHTML = '';
+  for (let date in daysWithVotes) {
+    let votes = daysWithVotes[date];
+    let weather = weatherForecasts?.[date];
+    daysView.append(createOneDayCard({ date, votes }, currentSession, weather))
+  }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+class FrontendState {
+
+  constructor() {
+    this.currentSession = undefined;
+    this.daysWithVotes = [];
+    this.weatherForecasts = {};
+  }
+
+  async refreshAllState(updateView = true) {
+    await Promise.all([
+      this.refreshVotesState(false),
+      // this.refreshWeatherState(false),
+      this.refreshSessionState(false),
+    ])
+    if (updateView) {
+      this.updateView();
+    }
+  }
+
+  async refreshVotesState(updateView = true) {
+    let { success, data, error } = await getVotesFromBackend()
+    if (success) {
+      this.daysWithVotes = data;
+    } else {
+      // ha ha I'm being lazy.  can you do better?
+      alert(error)
+    }
+    if (updateView) {
+      this.updateView();
+    }
+  }
+
+  async refreshWeatherState(updateView = true) {
+    let { success, data, error } = await getWeather()
+    if (success) {
+      this.weatherForecasts = data;
+    } else {
+      // ha ha I'm being lazy.  can you do better?
+      alert(error)
+    }
+    if (updateView) {
+      this.updateView();
+    }
+  }
+
+  async refreshSessionState(updateView = true) {
+    let { success, data, error } = await getSessionFromBackend()
+    if (success) {
+      this.currentSession = data;
+    } else {
+      // ha ha I'm being lazy.  can you do better?
+      alert(error)
+    }
+    if (updateView) {
+      this.updateView();
+    }
+  }
+
+  async updateView() {
+    // 1. update the whole frontend so that it shows relevant logged-in vs logged-out features
+    setLoggedIn(!!this.currentSession)
+
+    // 2. optionally update the header so that it shows the username of the person logged in
+    // updateHeader(this.currentSession)
+
+    // 3. render the days
+    updateVotableDays(this.daysWithVotes, this.currentSession, this.weatherForecasts)
+  }
+}
+
+
+
+
+const fes = new FrontendState();
+fes.refreshAllState()
+
+
+
+
+
+
+
+async function handleAuthEvent(event) {
+  event.preventDefault();
+  // console.log(event.currentTarget, event.target)
+  let usernameInput = event.currentTarget.querySelector('#headerusername');
+  let usernameValue = usernameInput.value;
+  let passwordInput = event.currentTarget.querySelector('#headerpassword');
+  let passwordValue = passwordInput.value;
+  let button = event.target.closest('button');
+  if (button) {
+    let authActionName = button?.dataset?.authAction;
+    let authActionFunction = {
+      signup: ajaxSignup,
+      login: ajaxLogin,
+      logout: ajaxLogout,
+    }[authActionName];
+    if (authActionFunction) {
+      let authResult = await authActionFunction(usernameValue, passwordValue);
+      if (authResult && authResult.success) {
+        await fes.refreshSessionState();
+        usernameInput.value = passwordInput.value = '';
+      } else if (authResult) {
+        // yo this is crap and if you want to be better than me, replace it.
+        alert(authResult.error)
+      } else {
+        // yo this is crap and if you want to be better than me, replace it.
+        alert("unknown network error")
+      }
+    }
+  }
+}
+
+const authform = document.querySelector('form.authform')
+authform.addEventListener("click", handleAuthEvent);
+
+
+
+
+
+async function handleVoteEvent(event) {
+  event.preventDefault();
+  let button = event.target.closest('button.vote');
+  // console.log(button)
+
+  if (button) {
+    let voteVal;
+    if (button.classList.contains('yes')) { voteVal = 'yes'; }
+    if (button.classList.contains('no')) { voteVal = 'no'; }
+    if (button.classList.contains('maybe')) { voteVal = 'maybe'; }
+    let cardDiv = button.closest("div.card");
+    if (!voteVal || !cardDiv) {
+      // console.log({ voteVal, cardDiv })
+      return;
+    }
+    let cardDate = cardDiv.dataset.date
+    let voteActionResult = await setMyVote(cardDate, voteVal)
+
+    if (voteActionResult) {
+      await fes.refreshVotesState();
+    }
+
+  }
+}
+
+const daysViewDiv = document.querySelector('section.days-view');
+daysViewDiv.addEventListener("click", handleVoteEvent);

+ 197 - 0
static/styles/styles.css

@@ -0,0 +1,197 @@
+:root {
+  /* TODO: make this all parametric */
+  --dark-back: burlywood;
+  --light-back: bisque;
+  --mid-accent: chocolate;
+  --angry: orangered;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+
+}
+
+body {
+  margin: 0;
+  background: var(--light-back);
+}
+
+.satis-tier {
+  /* delete this rule when you get to satis tier, obviously */
+  display: none;
+}
+
+.nodisplay {
+  display: none;
+}
+
+header .forloggedin,
+header .forloggedout {
+  display: none;
+}
+
+[data-logged-in=true] header .forloggedin {
+  display: initial;
+}
+
+[data-logged-in=false] header .forloggedout {
+  display: initial;
+}
+
+
+
+
+header {
+  display: flex;
+  justify-content: space-between;
+  background: var(--mid-accent);
+}
+
+header>* {
+  padding: 1em;
+}
+
+header .logo {
+  color: black;
+  font-size: x-large;
+  font-weight: bold;
+  text-decoration: unset;
+}
+
+
+
+
+
+.days-view {
+  display: flex;
+  flex-direction: row;
+  width: calc(100vw - 4em);
+  overflow-x: auto;
+  margin: 2em;
+  background-color: var(--dark-back);
+  border: 1px solid var(--mid-accent);
+  border-radius: 4px;
+
+}
+
+
+
+
+
+.days-view .card {
+  display: flex;
+  flex-direction: column;
+  background: var(--light-back);
+  margin: 8px;
+  padding: 8px;
+  border: 1px solid var(--mid-accent);
+  border-radius: 4px;
+  min-width: 12em;
+  min-height: 30em;
+
+}
+
+.days-view>.card>*:nth-child(2) {
+
+  margin: 1em 0.12em;
+}
+
+.days-view .card .cardtop {
+  padding: 3px;
+  border-radius: 3px;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  background-color: var(--dark-back);
+}
+
+.days-view .card .cardtop div.weather {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.days-view .card .cardtop div.weather img {
+  max-height: 35px;
+}
+
+
+
+
+
+.card .make-vote {
+  visibility: hidden;
+}
+
+[data-logged-in=true] .card .make-vote .forloggedin {
+  visibility: initial;
+}
+
+.days-view>.card>.vote {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+
+.days-view>.card>.vote button {
+  margin: 2px;
+  padding: 2px;
+  box-shadow: 1px 1px 0 black;
+  /* border-radius: 3px; */
+}
+
+.days-view>.card>.vote button.quiet {
+  opacity: 50%;
+}
+
+
+.days-view>.card>.vote button.loud {
+  font-weight: bold;
+  border-width: 3px;
+}
+
+
+
+
+
+.existing-votes div {
+  margin: 2px;
+  padding: 2px;
+  border-radius: 3px;
+}
+
+.existing-votes .vote-yes.vote-yes.vote-yes.vote-yes {
+  background: green;
+}
+
+.existing-votes .vote-no.vote-no.vote-no.vote-no {
+  background: red;
+}
+
+
+
+
+
+/* Scrollbar stuff.  Works on Firefox */
+.days-view {
+  scrollbar-width: thin;
+  scrollbar-color: var(--mid-accent) var(--light-back);
+}
+
+/* Scrollbar stuff.  Works on Chrome, Edge, and Safari */
+.days-view::-webkit-scrollbar {
+  width: 12px;
+}
+
+.days-view::-webkit-scrollbar-track {
+  background: var(--light-back);
+}
+
+.days-view::-webkit-scrollbar-thumb {
+  background-color: var(--mid-accent);
+  /* border-radius: 20px; */
+  border: 3px solid var(--light-back);
+}