Changes in / [bf01534:6993c72] in OpenWorkouts-current


Ignore:
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • README.txt

    rbf01534 r6993c72  
    22============
    33
    4 Getting Started
    5 ---------------
    6 
    7 - Change directory into your newly created project.
    8 
    9     cd ow
    10 
    11 - Create a Python virtual environment.
    12 
    13     python3 -m venv env
    14 
    15 - Upgrade packaging tools.
    16 
    17     env/bin/pip install --upgrade pip setuptools
    18 
    19 - Install the project in editable mode with its testing requirements.
    20 
    21     env/bin/pip install -e ".[testing]"
    22 
    23 - Run your project's tests.
    24 
    25     env/bin/pytest
    26 
    27 - Run your project.
    28 
    29     env/bin/pserve development.ini
     4TBW, if you look for installation instructions, take a look at the script
     5bin/install
  • bin/install

    rbf01534 r6993c72  
    5858install_openworkouts() {
    5959    . ${env_path}/bin/activate
    60     yes | pip install --upgrade --process-dependency-links -e ${current}[testing]
     60    yes | pip install --upgrade -e ${current}[testing]
    6161    deactivate
    6262}
  • ow/models/user.py

    rbf01534 r6993c72  
    99
    1010from ow.catalog import get_catalog, reindex_object
    11 from ow.utilities import get_week_days
     11from ow.utilities import get_week_days, get_month_week_number
    1212
    1313
     
    7777        reindex_object(catalog, workout)
    7878
    79     def workouts(self, year=None, month=None):
     79    def workouts(self, year=None, month=None, week=None):
    8080        """
    8181        Return this user workouts, sorted by date, from newer to older
     
    8484        if year:
    8585            workouts = [w for w in workouts if w.start.year == year]
    86         if month:
    87             workouts = [w for w in workouts if w.start.month == month]
     86            if month:
     87                workouts = [w for w in workouts if w.start.month == month]
     88            if week:
     89                week = int(week)
     90                workouts = [
     91                    w for w in workouts if w.start.isocalendar()[1] == week]
    8892        workouts = sorted(workouts, key=attrgetter('start'))
    8993        workouts.reverse()
     
    288292
    289293        return stats
     294
     295    @property
     296    def weekly_year_stats(self):
     297        """
     298        Return per-week stats for the last 12 months
     299        """
     300        # set the boundaries for looking for workouts afterwards,
     301        # we need the current date as the "end date" and one year
     302        # ago from that date. Then we set the start at the first
     303        # day of that month.
     304        end = datetime.now(timezone.utc)
     305        start = (end - timedelta(days=365)).replace(day=1)
     306
     307        stats = {}
     308
     309        # first initialize the stats dict
     310        for days in range((end - start).days):
     311            day = (start + timedelta(days=days)).date()
     312            week = day.isocalendar()[1]
     313            month_week = get_month_week_number(day)
     314            key = (day.year, day.month, week, month_week)
     315            if key not in stats.keys():
     316                stats[key] = {
     317                    'workouts': 0,
     318                    'time': timedelta(0),
     319                    'distance': Decimal(0),
     320                    'elevation': Decimal(0),
     321                    'sports': {}
     322                }
     323
     324        # now loop over the workouts, filtering and then adding stats
     325        # to the proper place
     326        for workout in self.workouts():
     327            if start.date() <= workout.start.date() <= end.date():
     328                # less typing, avoid long lines
     329                start_date = workout.start.date()
     330                week = start_date.isocalendar()[1]
     331                month_week = get_month_week_number(start_date)
     332                week = stats[(start_date.year,
     333                              start_date.month,
     334                              week,
     335                              month_week)]
     336
     337                week['workouts'] += 1
     338                week['time'] += workout.duration or timedelta(seconds=0)
     339                week['distance'] += workout.distance or Decimal(0)
     340                week['elevation'] += workout.uphill or Decimal(0)
     341                if workout.sport not in week['sports']:
     342                    week['sports'][workout.sport] = {
     343                        'workouts': 0,
     344                        'time': timedelta(seconds=0),
     345                        'distance': Decimal(0),
     346                        'elevation': Decimal(0),
     347                    }
     348                week['sports'][workout.sport]['workouts'] += 1
     349                week['sports'][workout.sport]['time'] += (
     350                    workout.duration or timedelta(0))
     351                week['sports'][workout.sport]['distance'] += (
     352                    workout.distance or Decimal(0))
     353                week['sports'][workout.sport]['elevation'] += (
     354                    workout.uphill or Decimal(0))
     355
     356        return stats
  • ow/static/css/main.css

    rbf01534 r6993c72  
    10061006<a class="login-remember" href="#">Forgot your password?</a>
    10071007*/
    1008 .user-profile img {
     1008.user-profile .workout-options {
     1009  font-size: 13px;
     1010  font-size: 0.8125rem;
     1011}
     1012.user-profile-account {
     1013  background-color: #fbfbfb;
     1014  padding: 2em  1em;
     1015}
     1016@media (min-width: 480px) {
     1017  .user-profile-account {
     1018    padding: 2em 6em;
     1019  }
     1020}
     1021.user-profile-account img {
    10091022  width: 140px;
    10101023  height: 140px;
     
    10131026  margin-bottom: 0.5em;
    10141027}
    1015 .user-profile .workout-options {
    1016   font-size: 13px;
    1017   font-size: 0.8125rem;
    1018 }
    1019 .user-profile-account {
    1020   background-color: #fbfbfb;
    1021   padding: 2em  1em;
    1022 }
    1023 @media (min-width: 480px) {
    1024   .user-profile-account {
    1025     padding: 2em 6em;
    1026   }
    1027 }
    10281028.user-profile-account h2 {
    10291029  margin: 0 0 0.15em 0;
     
    10401040  color: #959595;
    10411041}
    1042 .user-profile-account .workouts {
    1043   font-size: 18px;
    1044   font-size: 1.125rem;
     1042.total-workouts {
     1043  font-size: 13px;
     1044  font-size: 0.8125rem;
    10451045  font-weight: bold;
    10461046}
  • ow/static/js/ow.js

    rbf01534 r6993c72  
    244244    var chart_selector = spec.chart_selector,
    245245        filters_selector = spec.filters_selector,
    246         url = spec.url,
     246        switcher_selector = spec.switcher_selector,
     247        urls = spec.urls,
    247248        current_month = spec.current_month,
    248         y_axis_labels = spec.y_axis_labels;
     249        current_week = spec.current_week,
     250        y_axis_labels = spec.y_axis_labels,
     251        filter_by = spec.filter_by,
     252        url = spec.url;
    249253
    250254    // Helpers
     
    264268    };
    265269
     270    function get_name_for_x(d) {
     271        if (d.week == undefined || d.week == 0) {
     272            return d.name;
     273        }
     274        else {
     275            return d.id.split('-')[2];
     276        }
     277    }
     278
    266279    // Methods
    267280    var filters_setup = function filters_setup() {
    268281        $(filters_selector).on('click', function(e) {
    269             var filter_by = 'distance';
    270282            e.preventDefault();
    271283            filter_by = $(this).attr('class').split('-')[1]
    272284            var chart = d3.select(chart_selector);
    273285            chart.selectAll("*").remove();
    274             render(filter_by);
    275         });
    276     };
    277 
    278     var render = function render(filter_by) {
     286            render(filter_by, url);
     287        });
     288    };
     289
     290    var switcher_setup = function switcher_setup() {
     291        $(switcher_selector).on('click', function(e) {
     292            e.preventDefault();
     293            url = $(this).attr('class').split('-')[1]
     294            var chart = d3.select(chart_selector);
     295            chart.selectAll("*").remove();
     296            render(filter_by, url);
     297        });
     298    };
     299
     300    var render = function render(filter_by, url) {
    279301        /*
    280302          Build a d3 bar chart, populated with data from the given url.
     
    288310            y = d3.scaleLinear().rangeRound([height, 0]);
    289311
    290         d3.json(url).then(function (data) {
     312        d3.json(urls[url]).then(function (data) {
    291313            x.domain(data.map(function (d) {
    292                 return d.name;
     314                return get_name_for_x(d);
     315                // return d.name;
    293316            }));
    294317
     
    316339                .enter().append("rect")
    317340                .attr("class", function(d) {
    318                     if (d.id == current_month){
     341                    var sel_week = current_month + '-' + current_week;
     342                    if (d.id == current_month || d.id == sel_week){
     343                        /* Bar for the currently selected month or week */
    319344                        select_x_axis_label(d).attr('style', "font-weight: bold;");
    320                         return 'bar current'
     345                        return 'bar current';
    321346                    }
    322347                    else {
    323                         return 'bar'
     348                        if (!current_week && d.id.indexOf(current_month) >=0 ) {
     349                            /*
     350                               User selected a month, then switched to weekly
     351                               view, we do highlight all the bars for weeks in
     352                               that month
     353                            */
     354                            select_x_axis_label(d).attr('style', "font-weight: bold;");
     355                            return 'bar current';
     356                        }
     357                        else {
     358                            /* Non-selected bar */
     359                            return 'bar';
     360                        }
     361
    324362                    }
    325363                })
    326364                .attr("x", function (d) {
    327                     return x(d.name);
     365                    return x(get_name_for_x(d));
    328366                })
    329367                .attr("y", function (d) {
     
    343381                        select_x_axis_label(d).attr('style', "font-weight: regular;");
    344382                    }
     383                })
     384                .on('click', function(d) {
     385                    window.location.href = d.url;
    345386                });
    346387
    347             g.selectAll(".text")
    348                 .data(data)
    349                 .enter()
    350                 .append("text")
    351                 .attr("class","label")
    352                 .attr("x", function (d) {
    353                     return x(d.name) + x.bandwidth()/2;
    354                 })
    355                 .attr("y", function (d) {
    356                     /*
    357                       Get the value for the current bar, then get the maximum
    358                       value to be displayed in the bar, which is used to
    359                       calculate the proper position of the label for this bar,
    360                       relatively to its height (1% above the bar)
    361                      */
    362                     var value = get_y_value(d, filter_by);
    363                     var max = y.domain()[1];
    364                     return y(value + y.domain()[1] * 0.01);
    365                 })
    366                 .text(function(d) {
    367                     var value = get_y_value(d, filter_by)
    368                     if ( value > 0) {
    369                         return value;
    370                     }
    371                 });
    372 
     388            if (url == 'monthly') {
     389                g.selectAll(".text")
     390                    .data(data)
     391                    .enter()
     392                    .append("text")
     393                    .attr("class","label")
     394                    .attr("x", function (d) {
     395                        return x(get_name_for_x(d)) + x.bandwidth()/2;
     396                    })
     397                    .attr("y", function (d) {
     398                        /*
     399                          Get the value for the current bar, then get the maximum
     400                          value to be displayed in the bar, which is used to
     401                          calculate the proper position of the label for this bar,
     402                          relatively to its height (1% above the bar)
     403                        */
     404                        var value = get_y_value(d, filter_by);
     405                        var max = y.domain()[1];
     406                        return y(value + y.domain()[1] * 0.01);
     407                    })
     408                    .text(function(d) {
     409                        var value = get_y_value(d, filter_by)
     410                        if ( value > 0) {
     411                            return value;
     412                        }
     413                    });
     414            }
     415
     416            if (url == 'weekly') {
     417                g.selectAll(".tick")
     418                    .each(function (d, i) {
     419                        /*
     420                          Remove from the x-axis tickets those without letters
     421                          on them (useful for the weekly chart)
     422                        */
     423                        if (d !== parseInt(d, 10)) {
     424                            if(!d.match(/[a-z]/i)) {
     425                                this.remove();
     426                            }
     427                        }
     428                    });
     429            }
    373430        });
    374431    };
     
    376433    var that = {}
    377434    that.filters_setup = filters_setup;
     435    that.switcher_setup = switcher_setup;
    378436    that.render = render;
    379437    return that
  • ow/static/less/pages/profile.less

    rbf01534 r6993c72  
    11.user-profile {
    2     img {
    3         width: 140px;
    4         height: 140px;
    5         object-fit: cover;
    6         border-radius: 50%;
    7         margin-bottom: .5em;
    8     }
    92    .workout-options {
    103        .font-size(13);
     
    1710    @media (min-width: @screen-s){
    1811        padding: 2em 6em;
     12    }
     13    img {
     14        width: 140px;
     15        height: 140px;
     16        object-fit: cover;
     17        border-radius: 50%;
     18        margin-bottom: .5em;
    1919    }
    2020        h2 {
     
    3030                }
    3131        }
    32         .workouts {
    33                 .font-size(18);
    34                 font-weight: bold;
    35         }
     32}
     33.total-workouts {
     34        .font-size(13);
     35        font-weight: bold;
    3636}
    3737
  • ow/templates/profile.pt

    rbf01534 r6993c72  
    2626          <tal:has-nickname tal:condition="context.nickname">
    2727              <tal:nickname tal:content="context.nickname"></tal:nickname>
    28           </tal:has-nickname> | 
     28          </tal:has-nickname> |
    2929          <span><tal:email tal:content="context.email"></tal:email></span>
    3030        </p>
     
    6060             i18n:translate="">change password</a></li>
    6161        </ul>
     62      </div>
    6263
    63         <div class="workouts">
    64           <tal:w tal:replace="context.num_workouts"></tal:w>
    65           <tal:t i18n:translate="">Workouts</tal:t>
    66         </div>
     64      <div class="total-workouts">
     65        <tal:w tal:replace="context.num_workouts"></tal:w>
     66        <tal:t i18n:translate="">workouts</tal:t>
    6767      </div>
    6868
    6969      <div class="month-stats js-month-stats">
    7070        <div class="svg-cotent">
    71           <svg width="600" height="300" viewBox="0 0 600 300"></svg>
     71          <svg width="800" height="180" viewBox="0 0 800 180"></svg>
    7272        </div>
    7373        <ul class="workout-options filters js-filters">
     
    7676          <li><a href="#" class="js-elevation" i18n:translate="">elevation</a></li>
    7777        </ul>
     78
     79        <ul class="workout-options switcher js-switcher">
     80          <li><a href="#" class="js-weekly" i18n:translate="">weekly</a></li>
     81          <li><a href="#" class="js-monthly is-active" i18n:translate="">monthly</a></li>
     82        </ul>
    7883      </div>
    7984
    80       <div class="latest-workouts">
    81         <h3 i18n:translate="">Latest workouts</h3>
    82         <tal:r tal:repeat="workout context.workouts()[:5]">
    83           <div class="workout">
    84             <h4>
    85               <a href="" tal:content="workout.title"
     85
     86      <tal:r tal:repeat="workout workouts">
     87
     88        <a name="workouts"></a>
     89
     90
     91        <article class="workout-resume">
     92
     93          <h2 class="workout-title">
     94            <a href="" tal:content="workout.title"
    8695               tal:attributes="href request.resource_url(workout)"></a>
    87             </h4>
    88             <span><tal:c tal:content="workout.sport"></tal:c></span>
    89             <p><tal:c tal:content="workout.start"></tal:c>,
    90             <tal:c tal:content="workout.duration"></tal:c>,
    91             <tal:c tal:content="workout.rounded_distance"></tal:c> km
    92             </p>
     96          </h2>
     97
     98          <ul class="workout-info">
     99            <li>
     100              <tal:c tal:content="workout.start_in_timezone(context.timezone)"></tal:c>
     101            </li>
     102            <li>
     103              <!--! use the properly formatted duration instead of the timedelta object -->
     104              <tal:c tal:content="workout._duration"></tal:c>
     105            </li>
     106            <li tal:condition="workout.distance">
     107              <tal:c tal:content="workout.rounded_distance"></tal:c> km
     108            </li>
     109          </ul>
     110
     111          <ul class="workout-info" tal:define="hr workout.hr; cad workout.cad">
     112            <li tal:condition="hr">
     113              <span i18n:translate="">HR (bpm)</span>:
     114              <tal:c tal:content="hr['min']"></tal:c>
     115              <tal:t i18n:translate="">Min.</tal:t>,
     116              <tal:c tal:content="hr['avg']"></tal:c>
     117              <tal:t i18n:translate="">Avg.</tal:t>,
     118              <tal:c tal:content="hr['max']"></tal:c>
     119              <tal:t i18n:translate="">Max.</tal:t>
     120            </li>
     121            <li tal:condition="cad">
     122              <span i18n:translate="">Cad</span>:
     123              <tal:c tal:content="cad['min']"></tal:c>
     124              <tal:t i18n:translate="">Min.</tal:t>,
     125              <tal:c tal:content="cad['avg']"></tal:c>
     126              <tal:t i18n:translate="">Avg.</tal:t>,
     127              <tal:c tal:content="cad['max']"></tal:c>
     128              <tal:t i18n:translate="">Max.</tal:t>
     129            </li>
     130          </ul>
     131
     132          <div class="workout-intro" tal:content="workout.notes"></div>
     133
     134          <div class="workout-map" tal:condition="workout.has_gpx">
     135            <a href="" tal:attributes="href request.resource_url(workout)">
     136              <img src="" tal:attributes="src request.static_url(workout.map_screenshot);
     137                        alt workout.title; title workout.title">
     138            </a>
    93139          </div>
    94         </tal:r>
    95       </div>
    96140
     141        </article>
     142
     143      </tal:r>
    97144    </div>
    98145
     
    114161         chart_selector: '.js-month-stats svg',
    115162         filters_selector: '.js-month-stats .js-filters a',
    116          url: "${request.resource_url(context, 'yearly')}",
     163         switcher_selector: '.js-month-stats .js-switcher a',
     164         urls: {"monthly": "${request.resource_url(context, 'monthly')}",
     165                "weekly": "${request.resource_url(context, 'weekly')}"},
    117166         current_month: "${current_month}",
     167         current_week: "${current_week}",
    118168         y_axis_labels: y_axis_labels,
     169         filter_by: "distance",
     170         url: "${'monthly' if current_week is None else 'weekly'}",
    119171     });
    120      year_chart.render("distance");
     172     year_chart.render("distance", "${'monthly' if current_week is None else 'weekly'}");
    121173     year_chart.filters_setup();
     174     year_chart.switcher_setup();
    122175    </script>
    123176
  • ow/tests/views/test_user.py

    rbf01534 r6993c72  
    267267        """
    268268        request = dummy_request
     269        # profile page for the current day (no workouts avalable)
    269270        response = user_views.profile(john, request)
    270         assert len(response.keys()) == 1
     271        assert len(response.keys()) == 3
    271272        current_month = datetime.now(timezone.utc).strftime('%Y-%m')
    272273        assert response['current_month'] == current_month
     274        assert response['current_week'] is None
     275        assert response['workouts'] == []
     276        # profile page for a previous date, that has workouts
     277        request.GET['year'] = 2015
     278        request.GET['month'] = 8
     279        response = user_views.profile(john, request)
     280        assert len(response.keys()) == 3
     281        assert response['current_month'] == '2015-08'
     282        assert response['current_week'] is None
     283        assert response['workouts'] == john.workouts(2015, 8)
     284        # same, passing a week, first on a week without workouts
     285        request.GET['year'] = 2015
     286        request.GET['month'] = 8
     287        request.GET['week'] = 25
     288        response = user_views.profile(john, request)
     289        assert len(response.keys()) == 3
     290        assert response['current_month'] == '2015-08'
     291        assert response['current_week'] is 25
     292        assert response['workouts'] == []
     293        # now in a week with workoutss
     294        request.GET['year'] = 2015
     295        request.GET['month'] = 8
     296        request.GET['week'] = 26
     297        response = user_views.profile(john, request)
     298        assert len(response.keys()) == 3
     299        assert response['current_month'] == '2015-08'
     300        assert response['current_week'] is 26
     301        assert response['workouts'] == john.workouts(2015, 8)
    273302
    274303    def test_login_get(self, dummy_request):
  • ow/utilities.py

    rbf01534 r6993c72  
    22import os
    33import logging
     4import calendar
    45import subprocess
    56from datetime import datetime, timedelta
     
    234235    week_days = [first_day + timedelta(days=i) for i in range(7)]
    235236    return week_days
     237
     238
     239def get_month_week_number(day):
     240    """
     241    Given a datetime object (day), return the number of week the day is
     242    in the current month (week 1, 2, 3, etc)
     243    """
     244    weeks = calendar.monthcalendar(day.year, day.month)
     245    for week in weeks:
     246        if day.day in week:
     247            return weeks.index(week)
     248    return None
  • ow/views/user.py

    rbf01534 r6993c72  
    166166    """
    167167    now = datetime.now(timezone.utc)
     168    year = int(request.GET.get('year', now.year))
     169    month = int(request.GET.get('month', now.month))
     170    week = request.GET.get('week', None)
    168171    return {
    169         'current_month': now.strftime('%Y-%m')
     172        'workouts': context.workouts(year, month, week),
     173        'current_month': '{year}-{month}'.format(
     174            year=str(year), month=str(month).zfill(2)),
     175        'current_week': week
    170176    }
    171177
     
    258264    context=User,
    259265    permission='view',
    260     name='yearly')
     266    name='monthly')
    261267def last_months_stats(context, request):
    262268    """
     
    280286            'distance': int(round(stats[month]['distance'])),
    281287            'elevation': int(stats[month]['elevation']),
    282             'workouts': stats[month]['workouts']
     288            'workouts': stats[month]['workouts'],
     289            'url': request.resource_url(
     290                context, 'profile',
     291                query={'year': str(month[0]), 'month': str(month[1])},
     292                anchor='workouts')
    283293        }
    284294        json_stats.append(month_stats)
     
    286296                    charset='utf-8',
    287297                    body=json.dumps(json_stats))
     298
     299
     300@view_config(
     301    context=User,
     302    permission='view',
     303    name='weekly')
     304def last_weeks_stats(context, request):
     305    """
     306    Return a json-encoded stream with statistics for the last 12-months, but
     307    in a per-week basis
     308    """
     309    stats = context.weekly_year_stats
     310    # this sets which month is 2 times in the stats, once this year, once
     311    # the previous year. We will show it a bit different in the UI (showing
     312    # the year too to prevent confusion)
     313    repeated_month = datetime.now(timezone.utc).date().month
     314    json_stats = []
     315    for week in stats:
     316        hms = timedelta_to_hms(stats[week]['time'])
     317        name = month_name[week[1]][:3]
     318        if week[1] == repeated_month:
     319            name += ' ' + str(week[0])
     320        week_stats = {
     321            'id': '-'.join(
     322                [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
     323            'week': str(week[3]),  # the number of week in the current month
     324            'name': name,
     325            'time': str(hms[0]).zfill(2),
     326            'distance': int(round(stats[week]['distance'])),
     327            'elevation': int(stats[week]['elevation']),
     328            'workouts': stats[week]['workouts'],
     329            'url': request.resource_url(
     330                context, 'profile',
     331                query={'year': str(week[0]),
     332                       'month': str(week[1]),
     333                       'week': str(week[2])})
     334        }
     335        json_stats.append(week_stats)
     336    return Response(content_type='application/json',
     337                    charset='utf-8',
     338                    body=json.dumps(json_stats))
  • setup.py

    rbf01534 r6993c72  
    2121    'waitress',
    2222    'repoze.folder',
    23     'repoze.catalog==0.8.4',
     23    'repoze.catalog @ git+https://github.com/WuShell/repoze.catalog.git@0.8.4'
     24    '#egg=repoze.catalog-0.8.4',
    2425    'bcrypt',
    2526    'FormEncode',
     
    3940    'pytest-xdist',
    4041    'pytest-codestyle',
    41 ]
    42 
    43 dependency_links = [
    44     'git+https://github.com/WuShell/repoze.catalog.git@0.8.4'
    45     '#egg=repoze.catalog-0.8.4'
    4642]
    4743
     
    6460    include_package_data=True,
    6561    zip_safe=False,
    66     dependency_links=dependency_links,
    6762    extras_require={
    6863        'testing': tests_require,
Note: See TracChangeset for help on using the changeset viewer.